@bigcommerce/stencil-cli 8.6.6 → 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.
@@ -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', 'Prints environment and theme settings for debug purposes')
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;
@@ -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.6.6",
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"