@bigcommerce/stencil-cli 8.6.7 → 8.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/stencil-attributes-analyzer.js +21 -0
- package/bin/stencil.js +2 -1
- package/lib/StencilContextAnalyzer.js +780 -0
- package/lib/stencil-bundle.js +19 -0
- package/package.json +3 -2
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import 'colors';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import program from '../lib/commander.js';
|
|
5
|
+
import StencilContextAnalyzer from '../lib/StencilContextAnalyzer.js';
|
|
6
|
+
import { THEME_PATH, PACKAGE_INFO } from '../constants.js';
|
|
7
|
+
import { printCliResultErrorAndExit } from '../lib/cliCommon.js';
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.version(PACKAGE_INFO.version)
|
|
11
|
+
.option(
|
|
12
|
+
'-p, --path [path]',
|
|
13
|
+
'path where to save the output file (default: ./stencil-context.json)',
|
|
14
|
+
'./stencil-context.json',
|
|
15
|
+
)
|
|
16
|
+
.parse(process.argv);
|
|
17
|
+
const cliOptions = program.opts();
|
|
18
|
+
|
|
19
|
+
new StencilContextAnalyzer(path.join(THEME_PATH, 'templates'))
|
|
20
|
+
.analyzeAndExport(cliOptions.path)
|
|
21
|
+
.catch(printCliResultErrorAndExit);
|
package/bin/stencil.js
CHANGED
|
@@ -15,5 +15,6 @@ program
|
|
|
15
15
|
.command('pull', 'Pulls currently active theme config files and overwrites local copy')
|
|
16
16
|
.command('download', 'Downloads all the theme files')
|
|
17
17
|
.command('debug', 'Prints environment and theme settings for debug purposes')
|
|
18
|
-
.command('scss-autofix', '
|
|
18
|
+
.command('scss-autofix', 'Autofix SCSS files in the current directory')
|
|
19
|
+
.command('attributes-analyzer', 'Generates a report of all attributes used in the theme')
|
|
19
20
|
.parse(process.argv);
|
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
/* eslint-disable no-plusplus */
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import upath from 'upath';
|
|
4
|
+
import Paper from '@bigcommerce/stencil-paper';
|
|
5
|
+
|
|
6
|
+
class StencilContextAnalyzer {
|
|
7
|
+
constructor(templatesDir) {
|
|
8
|
+
this.templatesDir = templatesDir;
|
|
9
|
+
this.variableUsage = {};
|
|
10
|
+
this.templateExtensions = ['.html'];
|
|
11
|
+
this.paper = new Paper();
|
|
12
|
+
this.templateCache = new Map(); // Cache parsed templates
|
|
13
|
+
this.contextAnalysisCache = new Map(); // Cache context analysis results
|
|
14
|
+
this.processingStack = new Set(); // Track currently processing templates to prevent cycles
|
|
15
|
+
this.maxDepth = 10; // Maximum recursion depth
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Analyzes all page templates and tracks context flow to child templates
|
|
20
|
+
* @returns {Promise<Object>} Object with full variable paths as keys and usage counts as values
|
|
21
|
+
*/
|
|
22
|
+
async analyzeTemplates() {
|
|
23
|
+
const pageTemplates = await this.findPageTemplates();
|
|
24
|
+
|
|
25
|
+
// First pass: analyze page templates
|
|
26
|
+
if (pageTemplates.length > 0) {
|
|
27
|
+
const promises = pageTemplates.map((templateFile) => {
|
|
28
|
+
return this.analyzeTemplateWithContext(templateFile, 'root');
|
|
29
|
+
});
|
|
30
|
+
await Promise.all(promises);
|
|
31
|
+
} else {
|
|
32
|
+
console.warn('No page templates found in:', upath.join(this.templatesDir, 'pages'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Second pass: analyze standalone components/partials that weren't already analyzed
|
|
36
|
+
const allComponents = await this.findAllComponents();
|
|
37
|
+
const componentPromises = allComponents
|
|
38
|
+
.filter((templateFile) => {
|
|
39
|
+
// Check if this component hasn't been analyzed in any context yet
|
|
40
|
+
const hasBeenAnalyzed = Array.from(this.contextAnalysisCache.keys()).some((key) =>
|
|
41
|
+
key.startsWith(`${templateFile}:`),
|
|
42
|
+
);
|
|
43
|
+
return !hasBeenAnalyzed;
|
|
44
|
+
})
|
|
45
|
+
.map((templateFile) => {
|
|
46
|
+
// Determine appropriate context for the component
|
|
47
|
+
const relativePath = upath.relative(this.templatesDir, templateFile);
|
|
48
|
+
const context = this.isFormComponent(relativePath) ? 'form' : 'component';
|
|
49
|
+
return this.analyzeTemplateWithContext(templateFile, context);
|
|
50
|
+
});
|
|
51
|
+
await Promise.all(componentPromises);
|
|
52
|
+
|
|
53
|
+
return this.variableUsage;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Analyzes a template with a given context path (simplified approach)
|
|
58
|
+
* @param {string} templatePath - Path to the template file
|
|
59
|
+
* @param {string} contextPath - Current context path like 'root', 'product', 'products'
|
|
60
|
+
* @param {number} depth - Current recursion depth
|
|
61
|
+
* @param {Map|null} contextMapping - Parameter mapping for partials
|
|
62
|
+
*/
|
|
63
|
+
async analyzeTemplateWithContext(templatePath, contextPath, depth = 0, contextMapping = null) {
|
|
64
|
+
// Prevent excessive recursion depth
|
|
65
|
+
if (depth > this.maxDepth) {
|
|
66
|
+
console.warn(
|
|
67
|
+
`Warning: Maximum depth (${this.maxDepth}) reached for template ${templatePath} with context ${contextPath}`,
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Create cache key for this template + context combination
|
|
73
|
+
const cacheKey = `${templatePath}:${contextPath}`;
|
|
74
|
+
if (this.contextAnalysisCache.has(cacheKey)) {
|
|
75
|
+
return; // Already analyzed this template in this context
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check if we're already processing this template (circular reference)
|
|
79
|
+
if (this.processingStack.has(templatePath)) {
|
|
80
|
+
// console.warn(`Warning: Circular reference detected for template ${templatePath}`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Mark as being processed to prevent circular references
|
|
85
|
+
this.processingStack.add(templatePath);
|
|
86
|
+
// Mark in cache immediately to prevent race conditions
|
|
87
|
+
this.contextAnalysisCache.set(cacheKey, true);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const ast = await this.getOrParseTemplate(templatePath);
|
|
91
|
+
if (!ast) return;
|
|
92
|
+
|
|
93
|
+
const relativePath = upath.relative(this.templatesDir, templatePath);
|
|
94
|
+
|
|
95
|
+
// Use the passed context, but detect form components for legacy compatibility
|
|
96
|
+
let effectiveContextPath = contextPath;
|
|
97
|
+
if (contextPath === 'root' && this.isFormComponent(relativePath)) {
|
|
98
|
+
// Only override to 'form' context if we're coming from root context
|
|
99
|
+
// This maintains backward compatibility while allowing explicit context setting
|
|
100
|
+
effectiveContextPath = 'form';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await this.processASTWithContext(
|
|
104
|
+
ast,
|
|
105
|
+
effectiveContextPath,
|
|
106
|
+
relativePath,
|
|
107
|
+
depth,
|
|
108
|
+
contextMapping,
|
|
109
|
+
);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.warn(`Warning: Could not analyze template ${templatePath}: ${error.message}`);
|
|
112
|
+
} finally {
|
|
113
|
+
// Remove from processing stack when done
|
|
114
|
+
this.processingStack.delete(templatePath);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Gets cached template AST or parses it
|
|
120
|
+
* @param {string} templatePath - Path to template file
|
|
121
|
+
* @returns {Object|null} Parsed AST or null
|
|
122
|
+
*/
|
|
123
|
+
async getOrParseTemplate(templatePath) {
|
|
124
|
+
if (this.templateCache.has(templatePath)) {
|
|
125
|
+
return this.templateCache.get(templatePath);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const content = await fs.promises.readFile(templatePath, 'utf-8');
|
|
130
|
+
if (!content.trim()) {
|
|
131
|
+
this.templateCache.set(templatePath, null);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const ast = this.paper.renderer.handlebars.parse(content);
|
|
136
|
+
this.templateCache.set(templatePath, ast);
|
|
137
|
+
return ast;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.warn(`Warning: Could not parse template ${templatePath}: ${error.message}`);
|
|
140
|
+
this.templateCache.set(templatePath, null);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Processes AST nodes with context tracking (simplified)
|
|
147
|
+
* @param {Object|Array} node - AST node or array of nodes
|
|
148
|
+
* @param {string} contextPath - Current context path
|
|
149
|
+
* @param {string} templatePath - Relative template path
|
|
150
|
+
* @param {number} depth - Current recursion depth
|
|
151
|
+
* @param {Map|null} contextMapping - Parameter mapping for partials
|
|
152
|
+
*/
|
|
153
|
+
async processASTWithContext(node, contextPath, templatePath, depth = 0, contextMapping = null) {
|
|
154
|
+
if (Array.isArray(node)) {
|
|
155
|
+
for await (const child of node) {
|
|
156
|
+
await this.processASTWithContext(
|
|
157
|
+
child,
|
|
158
|
+
contextPath,
|
|
159
|
+
templatePath,
|
|
160
|
+
depth,
|
|
161
|
+
contextMapping,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!node || typeof node !== 'object') {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
switch (node.type) {
|
|
172
|
+
case 'Program':
|
|
173
|
+
await this.processASTWithContext(
|
|
174
|
+
node.body,
|
|
175
|
+
contextPath,
|
|
176
|
+
templatePath,
|
|
177
|
+
depth,
|
|
178
|
+
contextMapping,
|
|
179
|
+
);
|
|
180
|
+
break;
|
|
181
|
+
|
|
182
|
+
case 'MustacheStatement':
|
|
183
|
+
if (node.path && (!node.params || node.params.length === 0)) {
|
|
184
|
+
// Simple variable access
|
|
185
|
+
const fullPath = this.resolveVariablePath(
|
|
186
|
+
node.path,
|
|
187
|
+
contextPath,
|
|
188
|
+
contextMapping,
|
|
189
|
+
);
|
|
190
|
+
if (fullPath) {
|
|
191
|
+
this.recordVariableUsage(fullPath, templatePath);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Process parameters
|
|
195
|
+
if (node.params) {
|
|
196
|
+
for await (const param of node.params) {
|
|
197
|
+
await this.processASTWithContext(
|
|
198
|
+
param,
|
|
199
|
+
contextPath,
|
|
200
|
+
templatePath,
|
|
201
|
+
depth,
|
|
202
|
+
contextMapping,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Process hash parameters
|
|
207
|
+
if (node.hash && node.hash.pairs) {
|
|
208
|
+
for await (const pair of node.hash.pairs) {
|
|
209
|
+
if (pair.value) {
|
|
210
|
+
await this.processASTWithContext(
|
|
211
|
+
pair.value,
|
|
212
|
+
contextPath,
|
|
213
|
+
templatePath,
|
|
214
|
+
depth,
|
|
215
|
+
contextMapping,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
|
|
222
|
+
case 'PathExpression':
|
|
223
|
+
// eslint-disable-next-line no-case-declarations
|
|
224
|
+
const fullPath = this.resolveVariablePath(node, contextPath, contextMapping);
|
|
225
|
+
if (fullPath) {
|
|
226
|
+
this.recordVariableUsage(fullPath, templatePath);
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
|
|
230
|
+
case 'BlockStatement': {
|
|
231
|
+
// Process the block expression itself
|
|
232
|
+
await this.processASTWithContext(
|
|
233
|
+
node.path,
|
|
234
|
+
contextPath,
|
|
235
|
+
templatePath,
|
|
236
|
+
depth,
|
|
237
|
+
contextMapping,
|
|
238
|
+
);
|
|
239
|
+
if (node.params) {
|
|
240
|
+
for await (const param of node.params) {
|
|
241
|
+
await this.processASTWithContext(
|
|
242
|
+
param,
|
|
243
|
+
contextPath,
|
|
244
|
+
templatePath,
|
|
245
|
+
depth,
|
|
246
|
+
contextMapping,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Determine new context for block body
|
|
252
|
+
let newContextPath = contextPath;
|
|
253
|
+
if (node.path && node.path.original) {
|
|
254
|
+
const helperName = node.path.original;
|
|
255
|
+
if (helperName === 'each' && node.params && node.params[0]) {
|
|
256
|
+
// Handle both direct paths and helper calls
|
|
257
|
+
let arrayPath = null;
|
|
258
|
+
|
|
259
|
+
if (node.params[0].type === 'PathExpression') {
|
|
260
|
+
// Simple case: {{#each products}}
|
|
261
|
+
arrayPath = this.resolveVariablePath(
|
|
262
|
+
node.params[0],
|
|
263
|
+
contextPath,
|
|
264
|
+
contextMapping,
|
|
265
|
+
);
|
|
266
|
+
} else if (node.params[0].type === 'SubExpression') {
|
|
267
|
+
// Helper case: {{#each (limit products 5)}}
|
|
268
|
+
arrayPath = this.extractArrayPathFromSubExpression(
|
|
269
|
+
node.params[0],
|
|
270
|
+
contextPath,
|
|
271
|
+
contextMapping,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (arrayPath) {
|
|
276
|
+
// For form components, use simpler context paths
|
|
277
|
+
if (contextPath === 'form') {
|
|
278
|
+
newContextPath = 'form';
|
|
279
|
+
} else {
|
|
280
|
+
newContextPath = arrayPath;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} else if (helperName === 'with' && node.params && node.params[0]) {
|
|
284
|
+
const withPath = this.resolveVariablePath(
|
|
285
|
+
node.params[0],
|
|
286
|
+
contextPath,
|
|
287
|
+
contextMapping,
|
|
288
|
+
);
|
|
289
|
+
if (withPath) {
|
|
290
|
+
newContextPath = withPath;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Process block body
|
|
296
|
+
if (node.program) {
|
|
297
|
+
await this.processASTWithContext(
|
|
298
|
+
node.program,
|
|
299
|
+
newContextPath,
|
|
300
|
+
templatePath,
|
|
301
|
+
depth,
|
|
302
|
+
contextMapping,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
// Process else block
|
|
306
|
+
if (node.inverse) {
|
|
307
|
+
await this.processASTWithContext(
|
|
308
|
+
node.inverse,
|
|
309
|
+
contextPath,
|
|
310
|
+
templatePath,
|
|
311
|
+
depth,
|
|
312
|
+
contextMapping,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
case 'SubExpression': {
|
|
319
|
+
// Process sub-expression parameters
|
|
320
|
+
if (node.params) {
|
|
321
|
+
for await (const param of node.params) {
|
|
322
|
+
await this.processASTWithContext(
|
|
323
|
+
param,
|
|
324
|
+
contextPath,
|
|
325
|
+
templatePath,
|
|
326
|
+
depth,
|
|
327
|
+
contextMapping,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// Process hash parameters
|
|
332
|
+
if (node.hash && node.hash.pairs) {
|
|
333
|
+
for await (const pair of node.hash.pairs) {
|
|
334
|
+
if (pair.value) {
|
|
335
|
+
await this.processASTWithContext(
|
|
336
|
+
pair.value,
|
|
337
|
+
contextPath,
|
|
338
|
+
templatePath,
|
|
339
|
+
depth,
|
|
340
|
+
contextMapping,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
case 'PartialStatement': {
|
|
349
|
+
if (node.name && node.name.original) {
|
|
350
|
+
const partialName = node.name.original;
|
|
351
|
+
const partialPath = await this.resolvePartialPath(partialName);
|
|
352
|
+
|
|
353
|
+
// Process parameters first, even if partial can't be resolved
|
|
354
|
+
if (node.hash && node.hash.pairs && node.hash.pairs.length > 0) {
|
|
355
|
+
const paramPromises = node.hash.pairs.map(async (pair) => {
|
|
356
|
+
if (pair.value) {
|
|
357
|
+
// Handle different types of parameter values
|
|
358
|
+
if (pair.value.type === 'PathExpression') {
|
|
359
|
+
// Variable reference: value=product.reviews.text
|
|
360
|
+
const paramPath = this.resolveVariablePath(
|
|
361
|
+
pair.value,
|
|
362
|
+
contextPath,
|
|
363
|
+
contextMapping,
|
|
364
|
+
);
|
|
365
|
+
if (paramPath) {
|
|
366
|
+
// Record the parameter usage in the calling template
|
|
367
|
+
this.recordVariableUsage(paramPath, templatePath);
|
|
368
|
+
}
|
|
369
|
+
} else if (pair.value.type === 'SubExpression') {
|
|
370
|
+
// Helper call: label=(lang 'products.reviews.form_write.comments')
|
|
371
|
+
// Process the sub-expression to capture any variable usage
|
|
372
|
+
await this.processASTWithContext(
|
|
373
|
+
pair.value,
|
|
374
|
+
contextPath,
|
|
375
|
+
templatePath,
|
|
376
|
+
depth,
|
|
377
|
+
contextMapping,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
// Literal values (StringLiteral, BooleanLiteral, NumberLiteral) don't need processing
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await Promise.all(paramPromises);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (partialPath) {
|
|
388
|
+
// Determine context for the partial
|
|
389
|
+
const partialContext = contextPath;
|
|
390
|
+
let newContextMapping = null;
|
|
391
|
+
|
|
392
|
+
// If partial has explicit parameters (hash), create context mapping
|
|
393
|
+
if (node.hash && node.hash.pairs && node.hash.pairs.length > 0) {
|
|
394
|
+
newContextMapping = new Map();
|
|
395
|
+
|
|
396
|
+
// Create mapping from parameter name to actual context path
|
|
397
|
+
node.hash.pairs.forEach((pair) => {
|
|
398
|
+
if (pair.value && pair.value.type === 'PathExpression') {
|
|
399
|
+
const mappedValue = this.resolveVariablePath(
|
|
400
|
+
pair.value,
|
|
401
|
+
contextPath,
|
|
402
|
+
contextMapping,
|
|
403
|
+
);
|
|
404
|
+
if (mappedValue) {
|
|
405
|
+
newContextMapping.set(pair.key, mappedValue);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Recursively analyze the partial component with context mapping
|
|
412
|
+
await this.analyzeTemplateWithContext(
|
|
413
|
+
partialPath,
|
|
414
|
+
partialContext,
|
|
415
|
+
depth + 1,
|
|
416
|
+
newContextMapping,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Process regular parameters (if any)
|
|
422
|
+
if (node.params) {
|
|
423
|
+
for await (const param of node.params) {
|
|
424
|
+
await this.processASTWithContext(
|
|
425
|
+
param,
|
|
426
|
+
contextPath,
|
|
427
|
+
templatePath,
|
|
428
|
+
depth,
|
|
429
|
+
contextMapping,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
default:
|
|
437
|
+
// Process child nodes
|
|
438
|
+
for await (const key of Object.keys(node)) {
|
|
439
|
+
if (key !== 'type' && key !== 'loc' && node[key]) {
|
|
440
|
+
await this.processASTWithContext(
|
|
441
|
+
node[key],
|
|
442
|
+
contextPath,
|
|
443
|
+
templatePath,
|
|
444
|
+
depth,
|
|
445
|
+
contextMapping,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Resolves a variable path to its full context path (simplified)
|
|
454
|
+
* @param {Object} pathNode - PathExpression AST node
|
|
455
|
+
* @param {string} contextPath - Current context path
|
|
456
|
+
* @param {Map|null} contextMapping - Parameter mapping for partials
|
|
457
|
+
* @returns {string|null} Full variable path or null
|
|
458
|
+
*/
|
|
459
|
+
resolveVariablePath(pathNode, contextPath, contextMapping = null) {
|
|
460
|
+
if (!pathNode || pathNode.type !== 'PathExpression') {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const { original, parts } = pathNode;
|
|
465
|
+
|
|
466
|
+
// Skip Handlebars helpers
|
|
467
|
+
if (this.isHandlebarsHelper(original)) {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!parts || parts.length === 0) {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Check if this is a mapped parameter in a partial
|
|
476
|
+
if (contextMapping && contextMapping.has(parts[0])) {
|
|
477
|
+
const mappedPath = contextMapping.get(parts[0]);
|
|
478
|
+
if (parts.length === 1) {
|
|
479
|
+
return mappedPath;
|
|
480
|
+
}
|
|
481
|
+
// For nested properties like post.url, combine mapped path with remaining parts
|
|
482
|
+
const remainingParts = parts.slice(1);
|
|
483
|
+
return `${mappedPath}.${remainingParts.join('.')}`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Handle parent context references (../something)
|
|
487
|
+
if (original.startsWith('../')) {
|
|
488
|
+
const upLevels = (original.match(/\.\.\//g) || []).length;
|
|
489
|
+
const cleanParts = [...parts];
|
|
490
|
+
// Remove ../ prefixes
|
|
491
|
+
for (let i = 0; i < upLevels; i++) {
|
|
492
|
+
if (cleanParts[0] === '..') {
|
|
493
|
+
cleanParts.shift();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const remainingPath = cleanParts.join('.');
|
|
498
|
+
|
|
499
|
+
// Simple parent resolution
|
|
500
|
+
const parentPath = this.getParentContext(contextPath, upLevels);
|
|
501
|
+
if (parentPath === null) {
|
|
502
|
+
return remainingPath || null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return remainingPath ? `${parentPath}.${remainingPath}` : parentPath;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Build full path
|
|
509
|
+
const fullPath = parts.join('.');
|
|
510
|
+
|
|
511
|
+
if (contextPath === 'root') {
|
|
512
|
+
return fullPath;
|
|
513
|
+
}
|
|
514
|
+
return `${contextPath}.${fullPath}`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Simplified parent context resolution
|
|
519
|
+
* @param {string} contextPath - Current context path
|
|
520
|
+
* @param {number} levels - Levels to go up
|
|
521
|
+
* @returns {string|null} Parent context or null
|
|
522
|
+
*/
|
|
523
|
+
getParentContext(contextPath, levels) {
|
|
524
|
+
if (contextPath === 'root') {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
let currentPath = contextPath;
|
|
529
|
+
|
|
530
|
+
for (let i = 0; i < levels; i++) {
|
|
531
|
+
const lastBracketIndex = currentPath.lastIndexOf('[');
|
|
532
|
+
const lastDotIndex = currentPath.lastIndexOf('.');
|
|
533
|
+
|
|
534
|
+
if (lastBracketIndex > lastDotIndex && lastBracketIndex !== -1) {
|
|
535
|
+
// We're in an array context like "some.option.values[0]" (from legacy contexts)
|
|
536
|
+
// Going up one level should remove both the array index AND the array property
|
|
537
|
+
// So from "some.option.values[0]" we want "some.option", not "some.option.values"
|
|
538
|
+
const beforeArray = currentPath.substring(0, lastBracketIndex);
|
|
539
|
+
const lastDotBeforeArray = beforeArray.lastIndexOf('.');
|
|
540
|
+
|
|
541
|
+
if (lastDotBeforeArray > 0) {
|
|
542
|
+
currentPath = beforeArray.substring(0, lastDotBeforeArray);
|
|
543
|
+
} else {
|
|
544
|
+
// If there's no dot before the array, we go to root
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
} else if (lastDotIndex > 0) {
|
|
548
|
+
// Remove property
|
|
549
|
+
currentPath = currentPath.substring(0, lastDotIndex);
|
|
550
|
+
} else {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return currentPath || null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Records variable usage
|
|
559
|
+
* @param {string} variablePath - Full variable path
|
|
560
|
+
* @param {string} templatePath - Template path
|
|
561
|
+
*/
|
|
562
|
+
recordVariableUsage(variablePath, templatePath) {
|
|
563
|
+
if (!this.variableUsage[variablePath]) {
|
|
564
|
+
this.variableUsage[variablePath] = {
|
|
565
|
+
count: 0,
|
|
566
|
+
paths: [],
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
this.variableUsage[variablePath].count += 1;
|
|
571
|
+
|
|
572
|
+
const normalizedPath = upath.toUnix(templatePath);
|
|
573
|
+
if (!this.variableUsage[variablePath].paths.includes(normalizedPath)) {
|
|
574
|
+
this.variableUsage[variablePath].paths.push(normalizedPath);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Checks if a path is a Handlebars helper
|
|
580
|
+
* @param {string} pathOriginal - Original path string
|
|
581
|
+
* @returns {boolean} True if it's a helper
|
|
582
|
+
*/
|
|
583
|
+
isHandlebarsHelper(pathOriginal) {
|
|
584
|
+
try {
|
|
585
|
+
const registeredHelpers = this.paper.renderer.handlebars.helpers || {};
|
|
586
|
+
return pathOriginal in registeredHelpers;
|
|
587
|
+
} catch (error) {
|
|
588
|
+
const builtInHelpers = ['if', 'unless', 'each', 'with', 'lookup', 'log'];
|
|
589
|
+
return builtInHelpers.includes(pathOriginal);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Finds all page templates
|
|
595
|
+
* @returns {Promise<string[]>} Array of page template paths
|
|
596
|
+
*/
|
|
597
|
+
async findPageTemplates() {
|
|
598
|
+
const pagesDir = upath.join(this.templatesDir, 'pages');
|
|
599
|
+
const pageTemplates = [];
|
|
600
|
+
|
|
601
|
+
try {
|
|
602
|
+
await fs.promises.access(pagesDir);
|
|
603
|
+
} catch (error) {
|
|
604
|
+
console.warn(`Pages directory not accessible: ${pagesDir}`);
|
|
605
|
+
return [];
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const walkDir = async (dir) => {
|
|
609
|
+
try {
|
|
610
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
611
|
+
|
|
612
|
+
for await (const entry of entries) {
|
|
613
|
+
const fullPath = upath.join(dir, entry.name);
|
|
614
|
+
|
|
615
|
+
if (entry.isDirectory()) {
|
|
616
|
+
await walkDir(fullPath);
|
|
617
|
+
} else if (entry.isFile()) {
|
|
618
|
+
const ext = upath.extname(entry.name);
|
|
619
|
+
if (this.templateExtensions.includes(ext)) {
|
|
620
|
+
pageTemplates.push(fullPath);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
} catch (error) {
|
|
625
|
+
console.warn(`Error reading directory ${dir}: ${error.message}`);
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
await walkDir(pagesDir);
|
|
630
|
+
return pageTemplates;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Exports variable usage statistics to JSON
|
|
635
|
+
* @param {string} outputPath - Output file path
|
|
636
|
+
*/
|
|
637
|
+
async exportToJson(outputPath) {
|
|
638
|
+
const sortedUsage = Object.keys(this.variableUsage)
|
|
639
|
+
.sort()
|
|
640
|
+
.reduce(
|
|
641
|
+
(result, key) => ({
|
|
642
|
+
...result,
|
|
643
|
+
[key]: {
|
|
644
|
+
count: this.variableUsage[key].count,
|
|
645
|
+
paths: this.variableUsage[key].paths.sort(),
|
|
646
|
+
},
|
|
647
|
+
}),
|
|
648
|
+
{},
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
await fs.promises.writeFile(outputPath, JSON.stringify(sortedUsage, null, 2), 'utf-8');
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Analyzes and exports results
|
|
656
|
+
* @param {string} outputPath - Output file path
|
|
657
|
+
* @returns {Promise<Object>} Analysis results
|
|
658
|
+
*/
|
|
659
|
+
async analyzeAndExport(outputPath) {
|
|
660
|
+
const results = await this.analyzeTemplates();
|
|
661
|
+
await this.exportToJson(outputPath);
|
|
662
|
+
return results;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Resolves a partial name to its full file path
|
|
667
|
+
* @param {string} partialName - Name of the partial (e.g., 'components/common/header')
|
|
668
|
+
* @returns {Promise<string|null>} Full path to partial file or null if not found
|
|
669
|
+
*/
|
|
670
|
+
async resolvePartialPath(partialName) {
|
|
671
|
+
// Try different possible paths for the partial
|
|
672
|
+
const possiblePaths = [
|
|
673
|
+
upath.join(this.templatesDir, `${partialName}.html`),
|
|
674
|
+
upath.join(this.templatesDir, partialName, 'index.html'),
|
|
675
|
+
upath.join(this.templatesDir, `${partialName}/index.html`),
|
|
676
|
+
];
|
|
677
|
+
|
|
678
|
+
// Check each path sequentially until we find one that exists
|
|
679
|
+
return this.findFirstExistingPath(possiblePaths, partialName);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Helper method to find the first existing path from a list
|
|
684
|
+
* @param {string[]} paths - Array of paths to check
|
|
685
|
+
* @param {string} partialName - Name of the partial for error logging
|
|
686
|
+
* @returns {Promise<string|null>} First existing path or null
|
|
687
|
+
*/
|
|
688
|
+
async findFirstExistingPath(paths, partialName) {
|
|
689
|
+
const checkPath = async (index) => {
|
|
690
|
+
if (index >= paths.length) {
|
|
691
|
+
console.warn(`Warning: Could not resolve partial: ${partialName}`);
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
try {
|
|
696
|
+
await fs.promises.access(paths[index]);
|
|
697
|
+
return paths[index];
|
|
698
|
+
} catch (error) {
|
|
699
|
+
return checkPath(index + 1);
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
return checkPath(0);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Extracts the array path from a SubExpression (helper call)
|
|
708
|
+
* @param {Object} subExprNode - SubExpression AST node
|
|
709
|
+
* @param {string} contextPath - Current context path
|
|
710
|
+
* @param {Map|null} contextMapping - Parameter mapping for partials
|
|
711
|
+
* @returns {string|null} Array path or null
|
|
712
|
+
*/
|
|
713
|
+
extractArrayPathFromSubExpression(subExprNode, contextPath, contextMapping = null) {
|
|
714
|
+
if (!subExprNode || subExprNode.type !== 'SubExpression') {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// For helpers like (limit products 5), (filter products "active"), etc.
|
|
719
|
+
// we want to extract the first parameter which should be the array
|
|
720
|
+
if (subExprNode.params && subExprNode.params[0]) {
|
|
721
|
+
return this.resolveVariablePath(subExprNode.params[0], contextPath, contextMapping);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Checks if a template is a form component
|
|
729
|
+
* @param {string} templatePath - Path to the template file
|
|
730
|
+
* @returns {boolean} True if it's a form component
|
|
731
|
+
*/
|
|
732
|
+
isFormComponent(templatePath) {
|
|
733
|
+
// Detect form components based on their path
|
|
734
|
+
const normalizedPath = upath.toUnix(templatePath);
|
|
735
|
+
return (
|
|
736
|
+
normalizedPath.includes('/forms/') ||
|
|
737
|
+
normalizedPath.includes('/options/') ||
|
|
738
|
+
normalizedPath.startsWith('components/common/forms/') ||
|
|
739
|
+
normalizedPath.startsWith('components/products/options/')
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Finds all component/partial templates (excluding page templates)
|
|
745
|
+
* @returns {Promise<string[]>} Array of component/partial template paths
|
|
746
|
+
*/
|
|
747
|
+
async findAllComponents() {
|
|
748
|
+
const components = [];
|
|
749
|
+
|
|
750
|
+
const walkDir = async (dir) => {
|
|
751
|
+
try {
|
|
752
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
753
|
+
|
|
754
|
+
for await (const entry of entries) {
|
|
755
|
+
const fullPath = upath.join(dir, entry.name);
|
|
756
|
+
|
|
757
|
+
if (entry.isDirectory()) {
|
|
758
|
+
await walkDir(fullPath);
|
|
759
|
+
} else if (entry.isFile()) {
|
|
760
|
+
const ext = upath.extname(entry.name);
|
|
761
|
+
if (this.templateExtensions.includes(ext)) {
|
|
762
|
+
// Exclude page templates since they're analyzed in the first pass
|
|
763
|
+
const relativePath = upath.relative(this.templatesDir, fullPath);
|
|
764
|
+
if (!relativePath.startsWith('pages/')) {
|
|
765
|
+
components.push(fullPath);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
} catch (error) {
|
|
771
|
+
console.warn(`Error reading directory ${dir}: ${error.message}`);
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
await walkDir(this.templatesDir);
|
|
776
|
+
return components;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export default StencilContextAnalyzer;
|
package/lib/stencil-bundle.js
CHANGED
|
@@ -10,6 +10,7 @@ import BundleValidator from './bundle-validator.js';
|
|
|
10
10
|
import Cycles from './Cycles.js';
|
|
11
11
|
import langAssembler from './lang-assembler.js';
|
|
12
12
|
import templateAssembler from './template-assembler.js';
|
|
13
|
+
import StencilContextAnalyzer from './StencilContextAnalyzer.js';
|
|
13
14
|
import { recursiveReadDir } from './utils/fsUtils.js';
|
|
14
15
|
import { fetchRegions } from './regions.js';
|
|
15
16
|
|
|
@@ -65,6 +66,7 @@ class Bundle {
|
|
|
65
66
|
tasks.lang = this.assembleLangTask.bind(this);
|
|
66
67
|
tasks.schema = this.assembleSchema.bind(this);
|
|
67
68
|
tasks.schemaTranslations = this.assembleSchemaTranslations.bind(this);
|
|
69
|
+
tasks.stencilContext = this.assembleStencilContextTask.bind(this);
|
|
68
70
|
if (typeof buildConfigManager.production === 'function') {
|
|
69
71
|
tasks.theme = (callback) => {
|
|
70
72
|
console.log('Theme task Started...');
|
|
@@ -243,6 +245,19 @@ class Bundle {
|
|
|
243
245
|
return schema;
|
|
244
246
|
}
|
|
245
247
|
|
|
248
|
+
async assembleStencilContextTask() {
|
|
249
|
+
console.log('Stencil Context Analysis Started...');
|
|
250
|
+
try {
|
|
251
|
+
const analyzer = new StencilContextAnalyzer(this.templatesPath);
|
|
252
|
+
const variableUsage = await analyzer.analyzeTemplates();
|
|
253
|
+
console.log(`${'ok'.green} -- Stencil Context Analysis Finished`);
|
|
254
|
+
return variableUsage;
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.error('Stencil Context Analysis Failed:', err.message);
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
246
261
|
assembleLangTask(callback) {
|
|
247
262
|
console.log('Language Files Parsing Started...');
|
|
248
263
|
langAssembler.assemble((err, results) => {
|
|
@@ -443,6 +458,10 @@ class Bundle {
|
|
|
443
458
|
// append the parsed schemaTranslations.json file
|
|
444
459
|
archiveJsonFile(result, 'schemaTranslations.json');
|
|
445
460
|
break;
|
|
461
|
+
case 'stencilContext':
|
|
462
|
+
// append the stencil context analysis file
|
|
463
|
+
archiveJsonFile(result, 'parsed/stencilContext.json');
|
|
464
|
+
break;
|
|
446
465
|
case 'manifest':
|
|
447
466
|
// append the generated manifest.json file
|
|
448
467
|
archiveJsonFile(result, 'manifest.json');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bigcommerce/stencil-cli",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "CLI tool to run BigCommerce Stores locally for theme development.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"stencil-start": "./bin/stencil-start.js",
|
|
26
26
|
"stencil-release": "./bin/stencil-release.js",
|
|
27
27
|
"stencil-debug": "./bin/stencil-debug.js",
|
|
28
|
-
"stencil-scss-autofix": "./bin/stencil-scss-autofix.js"
|
|
28
|
+
"stencil-scss-autofix": "./bin/stencil-scss-autofix.js",
|
|
29
|
+
"stencil-attributes-analyzer": "./bin/stencil-attributes-analyzer.js"
|
|
29
30
|
},
|
|
30
31
|
"config": {
|
|
31
32
|
"stencil_version": "1.0"
|