diff --git a/bin/stencil-attributes-analyzer.js b/bin/stencil-attributes-analyzer.js new file mode 100644 index 00000000..42384452 --- /dev/null +++ b/bin/stencil-attributes-analyzer.js @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import 'colors'; +import path from 'path'; +import program from '../lib/commander.js'; +import StencilContextAnalyzer from '../lib/StencilContextAnalyzer.js'; +import { THEME_PATH, PACKAGE_INFO } from '../constants.js'; +import { printCliResultErrorAndExit } from '../lib/cliCommon.js'; + +program + .version(PACKAGE_INFO.version) + .option( + '-p, --path [path]', + 'path where to save the output file (default: ./stencil-context.json)', + './stencil-context.json', + ) + .parse(process.argv); +const cliOptions = program.opts(); + +new StencilContextAnalyzer(path.join(THEME_PATH, 'templates')) + .analyzeAndExport(cliOptions.path) + .catch(printCliResultErrorAndExit); diff --git a/bin/stencil.js b/bin/stencil.js index a14b3b9b..74261cdd 100755 --- a/bin/stencil.js +++ b/bin/stencil.js @@ -15,5 +15,6 @@ program .command('pull', 'Pulls currently active theme config files and overwrites local copy') .command('download', 'Downloads all the theme files') .command('debug', 'Prints environment and theme settings for debug purposes') - .command('scss-autofix', 'Prints environment and theme settings for debug purposes') + .command('scss-autofix', 'Autofix SCSS files in the current directory') + .command('attributes-analyzer', 'Generates a report of all attributes used in the theme') .parse(process.argv); diff --git a/lib/StencilContextAnalyzer.js b/lib/StencilContextAnalyzer.js new file mode 100644 index 00000000..979d0684 --- /dev/null +++ b/lib/StencilContextAnalyzer.js @@ -0,0 +1,780 @@ +/* eslint-disable no-plusplus */ +import fs from 'fs'; +import upath from 'upath'; +import Paper from '@bigcommerce/stencil-paper'; + +class StencilContextAnalyzer { + constructor(templatesDir) { + this.templatesDir = templatesDir; + this.variableUsage = {}; + this.templateExtensions = ['.html']; + this.paper = new Paper(); + this.templateCache = new Map(); // Cache parsed templates + this.contextAnalysisCache = new Map(); // Cache context analysis results + this.processingStack = new Set(); // Track currently processing templates to prevent cycles + this.maxDepth = 10; // Maximum recursion depth + } + + /** + * Analyzes all page templates and tracks context flow to child templates + * @returns {Promise} Object with full variable paths as keys and usage counts as values + */ + async analyzeTemplates() { + const pageTemplates = await this.findPageTemplates(); + + // First pass: analyze page templates + if (pageTemplates.length > 0) { + const promises = pageTemplates.map((templateFile) => { + return this.analyzeTemplateWithContext(templateFile, 'root'); + }); + await Promise.all(promises); + } else { + console.warn('No page templates found in:', upath.join(this.templatesDir, 'pages')); + } + + // Second pass: analyze standalone components/partials that weren't already analyzed + const allComponents = await this.findAllComponents(); + const componentPromises = allComponents + .filter((templateFile) => { + // Check if this component hasn't been analyzed in any context yet + const hasBeenAnalyzed = Array.from(this.contextAnalysisCache.keys()).some((key) => + key.startsWith(`${templateFile}:`), + ); + return !hasBeenAnalyzed; + }) + .map((templateFile) => { + // Determine appropriate context for the component + const relativePath = upath.relative(this.templatesDir, templateFile); + const context = this.isFormComponent(relativePath) ? 'form' : 'component'; + return this.analyzeTemplateWithContext(templateFile, context); + }); + await Promise.all(componentPromises); + + return this.variableUsage; + } + + /** + * Analyzes a template with a given context path (simplified approach) + * @param {string} templatePath - Path to the template file + * @param {string} contextPath - Current context path like 'root', 'product', 'products' + * @param {number} depth - Current recursion depth + * @param {Map|null} contextMapping - Parameter mapping for partials + */ + async analyzeTemplateWithContext(templatePath, contextPath, depth = 0, contextMapping = null) { + // Prevent excessive recursion depth + if (depth > this.maxDepth) { + console.warn( + `Warning: Maximum depth (${this.maxDepth}) reached for template ${templatePath} with context ${contextPath}`, + ); + return; + } + + // Create cache key for this template + context combination + const cacheKey = `${templatePath}:${contextPath}`; + if (this.contextAnalysisCache.has(cacheKey)) { + return; // Already analyzed this template in this context + } + + // Check if we're already processing this template (circular reference) + if (this.processingStack.has(templatePath)) { + // console.warn(`Warning: Circular reference detected for template ${templatePath}`); + return; + } + + // Mark as being processed to prevent circular references + this.processingStack.add(templatePath); + // Mark in cache immediately to prevent race conditions + this.contextAnalysisCache.set(cacheKey, true); + + try { + const ast = await this.getOrParseTemplate(templatePath); + if (!ast) return; + + const relativePath = upath.relative(this.templatesDir, templatePath); + + // Use the passed context, but detect form components for legacy compatibility + let effectiveContextPath = contextPath; + if (contextPath === 'root' && this.isFormComponent(relativePath)) { + // Only override to 'form' context if we're coming from root context + // This maintains backward compatibility while allowing explicit context setting + effectiveContextPath = 'form'; + } + + await this.processASTWithContext( + ast, + effectiveContextPath, + relativePath, + depth, + contextMapping, + ); + } catch (error) { + console.warn(`Warning: Could not analyze template ${templatePath}: ${error.message}`); + } finally { + // Remove from processing stack when done + this.processingStack.delete(templatePath); + } + } + + /** + * Gets cached template AST or parses it + * @param {string} templatePath - Path to template file + * @returns {Object|null} Parsed AST or null + */ + async getOrParseTemplate(templatePath) { + if (this.templateCache.has(templatePath)) { + return this.templateCache.get(templatePath); + } + + try { + const content = await fs.promises.readFile(templatePath, 'utf-8'); + if (!content.trim()) { + this.templateCache.set(templatePath, null); + return null; + } + + const ast = this.paper.renderer.handlebars.parse(content); + this.templateCache.set(templatePath, ast); + return ast; + } catch (error) { + console.warn(`Warning: Could not parse template ${templatePath}: ${error.message}`); + this.templateCache.set(templatePath, null); + return null; + } + } + + /** + * Processes AST nodes with context tracking (simplified) + * @param {Object|Array} node - AST node or array of nodes + * @param {string} contextPath - Current context path + * @param {string} templatePath - Relative template path + * @param {number} depth - Current recursion depth + * @param {Map|null} contextMapping - Parameter mapping for partials + */ + async processASTWithContext(node, contextPath, templatePath, depth = 0, contextMapping = null) { + if (Array.isArray(node)) { + for await (const child of node) { + await this.processASTWithContext( + child, + contextPath, + templatePath, + depth, + contextMapping, + ); + } + return; + } + + if (!node || typeof node !== 'object') { + return; + } + + switch (node.type) { + case 'Program': + await this.processASTWithContext( + node.body, + contextPath, + templatePath, + depth, + contextMapping, + ); + break; + + case 'MustacheStatement': + if (node.path && (!node.params || node.params.length === 0)) { + // Simple variable access + const fullPath = this.resolveVariablePath( + node.path, + contextPath, + contextMapping, + ); + if (fullPath) { + this.recordVariableUsage(fullPath, templatePath); + } + } + // Process parameters + if (node.params) { + for await (const param of node.params) { + await this.processASTWithContext( + param, + contextPath, + templatePath, + depth, + contextMapping, + ); + } + } + // Process hash parameters + if (node.hash && node.hash.pairs) { + for await (const pair of node.hash.pairs) { + if (pair.value) { + await this.processASTWithContext( + pair.value, + contextPath, + templatePath, + depth, + contextMapping, + ); + } + } + } + break; + + case 'PathExpression': + // eslint-disable-next-line no-case-declarations + const fullPath = this.resolveVariablePath(node, contextPath, contextMapping); + if (fullPath) { + this.recordVariableUsage(fullPath, templatePath); + } + break; + + case 'BlockStatement': { + // Process the block expression itself + await this.processASTWithContext( + node.path, + contextPath, + templatePath, + depth, + contextMapping, + ); + if (node.params) { + for await (const param of node.params) { + await this.processASTWithContext( + param, + contextPath, + templatePath, + depth, + contextMapping, + ); + } + } + + // Determine new context for block body + let newContextPath = contextPath; + if (node.path && node.path.original) { + const helperName = node.path.original; + if (helperName === 'each' && node.params && node.params[0]) { + // Handle both direct paths and helper calls + let arrayPath = null; + + if (node.params[0].type === 'PathExpression') { + // Simple case: {{#each products}} + arrayPath = this.resolveVariablePath( + node.params[0], + contextPath, + contextMapping, + ); + } else if (node.params[0].type === 'SubExpression') { + // Helper case: {{#each (limit products 5)}} + arrayPath = this.extractArrayPathFromSubExpression( + node.params[0], + contextPath, + contextMapping, + ); + } + + if (arrayPath) { + // For form components, use simpler context paths + if (contextPath === 'form') { + newContextPath = 'form'; + } else { + newContextPath = arrayPath; + } + } + } else if (helperName === 'with' && node.params && node.params[0]) { + const withPath = this.resolveVariablePath( + node.params[0], + contextPath, + contextMapping, + ); + if (withPath) { + newContextPath = withPath; + } + } + } + + // Process block body + if (node.program) { + await this.processASTWithContext( + node.program, + newContextPath, + templatePath, + depth, + contextMapping, + ); + } + // Process else block + if (node.inverse) { + await this.processASTWithContext( + node.inverse, + contextPath, + templatePath, + depth, + contextMapping, + ); + } + break; + } + + case 'SubExpression': { + // Process sub-expression parameters + if (node.params) { + for await (const param of node.params) { + await this.processASTWithContext( + param, + contextPath, + templatePath, + depth, + contextMapping, + ); + } + } + // Process hash parameters + if (node.hash && node.hash.pairs) { + for await (const pair of node.hash.pairs) { + if (pair.value) { + await this.processASTWithContext( + pair.value, + contextPath, + templatePath, + depth, + contextMapping, + ); + } + } + } + break; + } + + case 'PartialStatement': { + if (node.name && node.name.original) { + const partialName = node.name.original; + const partialPath = await this.resolvePartialPath(partialName); + + // Process parameters first, even if partial can't be resolved + if (node.hash && node.hash.pairs && node.hash.pairs.length > 0) { + const paramPromises = node.hash.pairs.map(async (pair) => { + if (pair.value) { + // Handle different types of parameter values + if (pair.value.type === 'PathExpression') { + // Variable reference: value=product.reviews.text + const paramPath = this.resolveVariablePath( + pair.value, + contextPath, + contextMapping, + ); + if (paramPath) { + // Record the parameter usage in the calling template + this.recordVariableUsage(paramPath, templatePath); + } + } else if (pair.value.type === 'SubExpression') { + // Helper call: label=(lang 'products.reviews.form_write.comments') + // Process the sub-expression to capture any variable usage + await this.processASTWithContext( + pair.value, + contextPath, + templatePath, + depth, + contextMapping, + ); + } + // Literal values (StringLiteral, BooleanLiteral, NumberLiteral) don't need processing + } + }); + + await Promise.all(paramPromises); + } + + if (partialPath) { + // Determine context for the partial + const partialContext = contextPath; + let newContextMapping = null; + + // If partial has explicit parameters (hash), create context mapping + if (node.hash && node.hash.pairs && node.hash.pairs.length > 0) { + newContextMapping = new Map(); + + // Create mapping from parameter name to actual context path + node.hash.pairs.forEach((pair) => { + if (pair.value && pair.value.type === 'PathExpression') { + const mappedValue = this.resolveVariablePath( + pair.value, + contextPath, + contextMapping, + ); + if (mappedValue) { + newContextMapping.set(pair.key, mappedValue); + } + } + }); + } + + // Recursively analyze the partial component with context mapping + await this.analyzeTemplateWithContext( + partialPath, + partialContext, + depth + 1, + newContextMapping, + ); + } + } + + // Process regular parameters (if any) + if (node.params) { + for await (const param of node.params) { + await this.processASTWithContext( + param, + contextPath, + templatePath, + depth, + contextMapping, + ); + } + } + break; + } + + default: + // Process child nodes + for await (const key of Object.keys(node)) { + if (key !== 'type' && key !== 'loc' && node[key]) { + await this.processASTWithContext( + node[key], + contextPath, + templatePath, + depth, + contextMapping, + ); + } + } + } + } + + /** + * Resolves a variable path to its full context path (simplified) + * @param {Object} pathNode - PathExpression AST node + * @param {string} contextPath - Current context path + * @param {Map|null} contextMapping - Parameter mapping for partials + * @returns {string|null} Full variable path or null + */ + resolveVariablePath(pathNode, contextPath, contextMapping = null) { + if (!pathNode || pathNode.type !== 'PathExpression') { + return null; + } + + const { original, parts } = pathNode; + + // Skip Handlebars helpers + if (this.isHandlebarsHelper(original)) { + return null; + } + + if (!parts || parts.length === 0) { + return null; + } + + // Check if this is a mapped parameter in a partial + if (contextMapping && contextMapping.has(parts[0])) { + const mappedPath = contextMapping.get(parts[0]); + if (parts.length === 1) { + return mappedPath; + } + // For nested properties like post.url, combine mapped path with remaining parts + const remainingParts = parts.slice(1); + return `${mappedPath}.${remainingParts.join('.')}`; + } + + // Handle parent context references (../something) + if (original.startsWith('../')) { + const upLevels = (original.match(/\.\.\//g) || []).length; + const cleanParts = [...parts]; + // Remove ../ prefixes + for (let i = 0; i < upLevels; i++) { + if (cleanParts[0] === '..') { + cleanParts.shift(); + } + } + + const remainingPath = cleanParts.join('.'); + + // Simple parent resolution + const parentPath = this.getParentContext(contextPath, upLevels); + if (parentPath === null) { + return remainingPath || null; + } + + return remainingPath ? `${parentPath}.${remainingPath}` : parentPath; + } + + // Build full path + const fullPath = parts.join('.'); + + if (contextPath === 'root') { + return fullPath; + } + return `${contextPath}.${fullPath}`; + } + + /** + * Simplified parent context resolution + * @param {string} contextPath - Current context path + * @param {number} levels - Levels to go up + * @returns {string|null} Parent context or null + */ + getParentContext(contextPath, levels) { + if (contextPath === 'root') { + return null; + } + + let currentPath = contextPath; + + for (let i = 0; i < levels; i++) { + const lastBracketIndex = currentPath.lastIndexOf('['); + const lastDotIndex = currentPath.lastIndexOf('.'); + + if (lastBracketIndex > lastDotIndex && lastBracketIndex !== -1) { + // We're in an array context like "some.option.values[0]" (from legacy contexts) + // Going up one level should remove both the array index AND the array property + // So from "some.option.values[0]" we want "some.option", not "some.option.values" + const beforeArray = currentPath.substring(0, lastBracketIndex); + const lastDotBeforeArray = beforeArray.lastIndexOf('.'); + + if (lastDotBeforeArray > 0) { + currentPath = beforeArray.substring(0, lastDotBeforeArray); + } else { + // If there's no dot before the array, we go to root + return null; + } + } else if (lastDotIndex > 0) { + // Remove property + currentPath = currentPath.substring(0, lastDotIndex); + } else { + return null; + } + } + return currentPath || null; + } + + /** + * Records variable usage + * @param {string} variablePath - Full variable path + * @param {string} templatePath - Template path + */ + recordVariableUsage(variablePath, templatePath) { + if (!this.variableUsage[variablePath]) { + this.variableUsage[variablePath] = { + count: 0, + paths: [], + }; + } + + this.variableUsage[variablePath].count += 1; + + const normalizedPath = upath.toUnix(templatePath); + if (!this.variableUsage[variablePath].paths.includes(normalizedPath)) { + this.variableUsage[variablePath].paths.push(normalizedPath); + } + } + + /** + * Checks if a path is a Handlebars helper + * @param {string} pathOriginal - Original path string + * @returns {boolean} True if it's a helper + */ + isHandlebarsHelper(pathOriginal) { + try { + const registeredHelpers = this.paper.renderer.handlebars.helpers || {}; + return pathOriginal in registeredHelpers; + } catch (error) { + const builtInHelpers = ['if', 'unless', 'each', 'with', 'lookup', 'log']; + return builtInHelpers.includes(pathOriginal); + } + } + + /** + * Finds all page templates + * @returns {Promise} Array of page template paths + */ + async findPageTemplates() { + const pagesDir = upath.join(this.templatesDir, 'pages'); + const pageTemplates = []; + + try { + await fs.promises.access(pagesDir); + } catch (error) { + console.warn(`Pages directory not accessible: ${pagesDir}`); + return []; + } + + const walkDir = async (dir) => { + try { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + + for await (const entry of entries) { + const fullPath = upath.join(dir, entry.name); + + if (entry.isDirectory()) { + await walkDir(fullPath); + } else if (entry.isFile()) { + const ext = upath.extname(entry.name); + if (this.templateExtensions.includes(ext)) { + pageTemplates.push(fullPath); + } + } + } + } catch (error) { + console.warn(`Error reading directory ${dir}: ${error.message}`); + } + }; + + await walkDir(pagesDir); + return pageTemplates; + } + + /** + * Exports variable usage statistics to JSON + * @param {string} outputPath - Output file path + */ + async exportToJson(outputPath) { + const sortedUsage = Object.keys(this.variableUsage) + .sort() + .reduce( + (result, key) => ({ + ...result, + [key]: { + count: this.variableUsage[key].count, + paths: this.variableUsage[key].paths.sort(), + }, + }), + {}, + ); + + await fs.promises.writeFile(outputPath, JSON.stringify(sortedUsage, null, 2), 'utf-8'); + } + + /** + * Analyzes and exports results + * @param {string} outputPath - Output file path + * @returns {Promise} Analysis results + */ + async analyzeAndExport(outputPath) { + const results = await this.analyzeTemplates(); + await this.exportToJson(outputPath); + return results; + } + + /** + * Resolves a partial name to its full file path + * @param {string} partialName - Name of the partial (e.g., 'components/common/header') + * @returns {Promise} Full path to partial file or null if not found + */ + async resolvePartialPath(partialName) { + // Try different possible paths for the partial + const possiblePaths = [ + upath.join(this.templatesDir, `${partialName}.html`), + upath.join(this.templatesDir, partialName, 'index.html'), + upath.join(this.templatesDir, `${partialName}/index.html`), + ]; + + // Check each path sequentially until we find one that exists + return this.findFirstExistingPath(possiblePaths, partialName); + } + + /** + * Helper method to find the first existing path from a list + * @param {string[]} paths - Array of paths to check + * @param {string} partialName - Name of the partial for error logging + * @returns {Promise} First existing path or null + */ + async findFirstExistingPath(paths, partialName) { + const checkPath = async (index) => { + if (index >= paths.length) { + console.warn(`Warning: Could not resolve partial: ${partialName}`); + return null; + } + + try { + await fs.promises.access(paths[index]); + return paths[index]; + } catch (error) { + return checkPath(index + 1); + } + }; + + return checkPath(0); + } + + /** + * Extracts the array path from a SubExpression (helper call) + * @param {Object} subExprNode - SubExpression AST node + * @param {string} contextPath - Current context path + * @param {Map|null} contextMapping - Parameter mapping for partials + * @returns {string|null} Array path or null + */ + extractArrayPathFromSubExpression(subExprNode, contextPath, contextMapping = null) { + if (!subExprNode || subExprNode.type !== 'SubExpression') { + return null; + } + + // For helpers like (limit products 5), (filter products "active"), etc. + // we want to extract the first parameter which should be the array + if (subExprNode.params && subExprNode.params[0]) { + return this.resolveVariablePath(subExprNode.params[0], contextPath, contextMapping); + } + + return null; + } + + /** + * Checks if a template is a form component + * @param {string} templatePath - Path to the template file + * @returns {boolean} True if it's a form component + */ + isFormComponent(templatePath) { + // Detect form components based on their path + const normalizedPath = upath.toUnix(templatePath); + return ( + normalizedPath.includes('/forms/') || + normalizedPath.includes('/options/') || + normalizedPath.startsWith('components/common/forms/') || + normalizedPath.startsWith('components/products/options/') + ); + } + + /** + * Finds all component/partial templates (excluding page templates) + * @returns {Promise} Array of component/partial template paths + */ + async findAllComponents() { + const components = []; + + const walkDir = async (dir) => { + try { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + + for await (const entry of entries) { + const fullPath = upath.join(dir, entry.name); + + if (entry.isDirectory()) { + await walkDir(fullPath); + } else if (entry.isFile()) { + const ext = upath.extname(entry.name); + if (this.templateExtensions.includes(ext)) { + // Exclude page templates since they're analyzed in the first pass + const relativePath = upath.relative(this.templatesDir, fullPath); + if (!relativePath.startsWith('pages/')) { + components.push(fullPath); + } + } + } + } + } catch (error) { + console.warn(`Error reading directory ${dir}: ${error.message}`); + } + }; + + await walkDir(this.templatesDir); + return components; + } +} + +export default StencilContextAnalyzer; diff --git a/lib/StencilContextAnalyzer.spec.js b/lib/StencilContextAnalyzer.spec.js new file mode 100644 index 00000000..87720497 --- /dev/null +++ b/lib/StencilContextAnalyzer.spec.js @@ -0,0 +1,567 @@ +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import StencilContextAnalyzer from './StencilContextAnalyzer.js'; + +describe('StencilContextAnalyzer', () => { + const validThemePath = path.join(process.cwd(), 'test/_mocks/themes/valid'); + const validTemplatesPath = path.join(validThemePath, 'templates'); + let tempDir; + + beforeEach(async () => { + tempDir = await fs.promises.mkdtemp('stencil-context-analyzer-test-'); + }); + + afterEach(async () => { + if (tempDir && fs.existsSync(tempDir)) { + await fs.promises.rm(tempDir, { recursive: true }); + } + }); + + describe('Happy Path', () => { + it('should analyze page templates and track context flow', async () => { + const analyzer = new StencilContextAnalyzer(validTemplatesPath); + const results = await analyzer.analyzeTemplates(); + + expect(results).toBeInstanceOf(Object); + expect(Object.keys(results).length).toBeGreaterThan(0); + + // Verify expected variables from page templates with full context paths + expect(results['head.scripts']).toBeDefined(); + expect(results['head.scripts'].count).toBeGreaterThan(0); + expect(results['head.scripts'].paths).toContain('pages/page.html'); + + expect(results['theme_settings.display_that']).toBeDefined(); + expect(results['theme_settings.customizable_title']).toBeDefined(); + expect(results['footer.scripts']).toBeDefined(); + + // Verify path tracking + expect(results['head.scripts'].paths).toEqual( + expect.arrayContaining(['pages/page.html', 'pages/page3.html']), + ); + }); + + it('should capture form variables with form context in second pass', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const pagesDir = path.join(testTemplatesDir, 'pages'); + const formsDir = path.join(testTemplatesDir, 'components', 'common', 'forms'); + await fs.promises.mkdir(pagesDir, { recursive: true }); + await fs.promises.mkdir(formsDir, { recursive: true }); + + // Create a page template + const pageTemplate = ` +
+

{{product.name}}

+ {{> components/common/forms/text}} +
`; + + // Create a form component with standalone variables + const formTemplate = ` + +{{#if hasError}}{{errorMessage}}{{/if}}`; + + await fs.promises.writeFile(path.join(pagesDir, 'product.html'), pageTemplate); + await fs.promises.writeFile(path.join(formsDir, 'text.html'), formTemplate); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + // Verify page template context tracking + expect(results['product.name']).toBeDefined(); + expect(results['product.name'].paths).toContain('pages/product.html'); + + // Verify form variables captured with form context + expect(results['form.required']).toBeDefined(); + expect(results['form.name']).toBeDefined(); + expect(results['form.defaultValue']).toBeDefined(); + expect(results['form.hasError']).toBeDefined(); + expect(results['form.errorMessage']).toBeDefined(); + // Verify form variables are attributed to the form component + expect(results['form.required'].paths).toContain('components/common/forms/text.html'); + }); + + it('should track full context paths through nested loops', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const pagesDir = path.join(testTemplatesDir, 'pages'); + await fs.promises.mkdir(pagesDir, { recursive: true }); + + const nestedTemplate = ` +
+

{{store.name}}

+

{{store.address}}

+ + {{#each products}} +
+

Store: {{../store.name}}

+

Address: {{../store.address}}

+

{{name}}

+
+ {{/each}} +
`; + + await fs.promises.writeFile(path.join(pagesDir, 'catalog.html'), nestedTemplate); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + // Verify context path tracking (note: parent context resolution may create unified paths) + expect(results['store.name']).toBeDefined(); + expect(results['store.address']).toBeDefined(); + expect(results['products.name']).toBeDefined(); + + // Note: The exact count depends on how parent context (../) is resolved + // Both direct access and parent context access should be tracked + expect(results['store.name'].count).toBeGreaterThanOrEqual(1); + expect(results['store.address'].count).toBeGreaterThanOrEqual(1); + + // Verify no unresolved parent context references + const parentRefs = Object.keys(results).filter((key) => key.startsWith('../')); + expect(parentRefs).toHaveLength(0); + }); + + it('should detect variables in helper parameters', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const pagesDir = path.join(testTemplatesDir, 'pages'); + await fs.promises.mkdir(pagesDir, { recursive: true }); + + const helpersTemplate = ` +
+ {{#if (compare product.price ">" 100)}} + Expensive: {{product.name}} + {{/if}} + + {{#unless (isEmpty cart.items)}} +

Items: {{cart.count}}

+ {{/unless}} + + {{currency product.price settings.currency}} +
`; + + await fs.promises.writeFile(path.join(pagesDir, 'product.html'), helpersTemplate); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + // Verify helper parameter variables are detected + expect(results['product.price']).toBeDefined(); + expect(results['product.price'].count).toBe(2); // Once in compare, once in currency + expect(results['cart.items']).toBeDefined(); + expect(results['settings.currency']).toBeDefined(); + expect(results['product.name']).toBeDefined(); + expect(results['cart.count']).toBeDefined(); + }); + + it('should export results to JSON file', async () => { + const analyzer = new StencilContextAnalyzer(validTemplatesPath); + const outputPath = path.join(tempDir, 'output.json'); + + const results = await analyzer.analyzeAndExport(outputPath); + + expect(results).toBeInstanceOf(Object); + expect(fs.existsSync(outputPath)).toBe(true); + + const jsonContent = JSON.parse(fs.readFileSync(outputPath, 'utf-8')); + expect(jsonContent).toEqual(results); + + // Verify JSON structure + Object.values(jsonContent).forEach((variable) => { + expect(variable).toHaveProperty('count'); + expect(variable).toHaveProperty('paths'); + expect(typeof variable.count).toBe('number'); + expect(Array.isArray(variable.paths)).toBe(true); + }); + }); + + it('should handle deeply nested block statements correctly', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const pagesDir = path.join(testTemplatesDir, 'pages'); + await fs.promises.mkdir(pagesDir, { recursive: true }); + + const nestedTemplate = ` +
+ {{#each products}} +
+

{{name}}

+ + {{#each variants}} +
+ {{name}} ({{../name}}) + + {{#with details}} +

{{color}} - {{size}}

+

Product: {{../../name}}

+ {{/with}} +
+ {{/each}} +
+ {{/each}} +
`; + + await fs.promises.writeFile(path.join(pagesDir, 'catalog.html'), nestedTemplate); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + // Verify deeply nested context handling + expect(results.products).toBeDefined(); + expect(results['products.name']).toBeDefined(); + expect(results['products.variants']).toBeDefined(); + expect(results['products.variants.name']).toBeDefined(); + expect(results['products.variants.details']).toBeDefined(); + expect(results['products.variants.details.color']).toBeDefined(); + expect(results['products.variants.details.size']).toBeDefined(); + + // Verify context tracking (exact count may vary based on parent context resolution) + expect(results['products.name'].count).toBeGreaterThanOrEqual(1); + }); + + it('should handle partial parameters correctly', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const pagesDir = path.join(testTemplatesDir, 'pages'); + await fs.promises.mkdir(pagesDir, { recursive: true }); + + const pageWithPartials = ` +
+ {{> components/product-card product=featured_product}} + {{> components/user-info user=current_user email=user.email}} +
`; + + await fs.promises.writeFile(path.join(pagesDir, 'home.html'), pageWithPartials); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + // Verify partial parameters are tracked + expect(results.featured_product).toBeDefined(); + expect(results.current_user).toBeDefined(); + expect(results['user.email']).toBeDefined(); + }); + }); + + describe('Error Handling', () => { + it('should handle non-existent pages directory', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + await fs.promises.mkdir(testTemplatesDir, { recursive: true }); + // Note: not creating pages/ subdirectory + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + expect(results).toEqual({}); + }); + + it('should handle empty pages directory', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const pagesDir = path.join(testTemplatesDir, 'pages'); + await fs.promises.mkdir(pagesDir, { recursive: true }); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + expect(results).toEqual({}); + }); + + it('should handle invalid Handlebars syntax gracefully', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const pagesDir = path.join(testTemplatesDir, 'pages'); + await fs.promises.mkdir(pagesDir, { recursive: true }); + + const invalidTemplate = ` +
+ {{#if unclosed_block}} +

This block is not closed properly

+ + + {{invalid.handlebars.syntax{{}} + {{}} +
`; + + await fs.promises.writeFile(path.join(pagesDir, 'invalid.html'), invalidTemplate); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + + // Should not throw but warn and continue + const results = await analyzer.analyzeTemplates(); + expect(results).toBeInstanceOf(Object); + }); + + it('should handle empty template files', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const pagesDir = path.join(testTemplatesDir, 'pages'); + await fs.promises.mkdir(pagesDir, { recursive: true }); + + // Create various empty files + await fs.promises.writeFile(path.join(pagesDir, 'empty.html'), ''); + await fs.promises.writeFile(path.join(pagesDir, 'whitespace.html'), ' \n\t '); + await fs.promises.writeFile( + path.join(pagesDir, 'valid.html'), + '

{{valid.variable}}

', + ); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + // Should only contain variables from valid template + expect(results['valid.variable']).toBeDefined(); + expect(Object.keys(results)).toHaveLength(1); + }); + + it('should handle templates with only HTML content (no variables)', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const pagesDir = path.join(testTemplatesDir, 'pages'); + await fs.promises.mkdir(pagesDir, { recursive: true }); + + const htmlOnlyTemplate = ` + + + + Static Title + + +

Static Content

+

No Handlebars variables here

+ +`; + + await fs.promises.writeFile(path.join(pagesDir, 'static.html'), htmlOnlyTemplate); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + expect(results).toEqual({}); + }); + + it('should handle file read permissions errors gracefully', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const pagesDir = path.join(testTemplatesDir, 'pages'); + await fs.promises.mkdir(pagesDir, { recursive: true }); + + const testFile = path.join(pagesDir, 'test.html'); + await fs.promises.writeFile(testFile, '

{{test.variable}}

'); + + // Mock fs.promises.readFile to simulate permission error for this specific file + const originalReadFile = fs.promises.readFile; + fs.promises.readFile = jest.fn().mockImplementation((filePath, encoding) => { + if (filePath === testFile) { + return Promise.reject(new Error('EACCES: permission denied')); + } + return originalReadFile(filePath, encoding); + }); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + // Should handle error gracefully and continue with other templates + expect(results).toBeInstanceOf(Object); + + // Restore original function + fs.promises.readFile = originalReadFile; + }); + + it('should handle export to JSON file in non-existent directory', async () => { + const analyzer = new StencilContextAnalyzer(validTemplatesPath); + const nonExistentDir = path.join(tempDir, 'non-existent', 'deep', 'path'); + const outputPath = path.join(nonExistentDir, 'output.json'); + + // Should throw an error when trying to write to non-existent directory + await expect(analyzer.analyzeAndExport(outputPath)).rejects.toThrow(); + }); + + it('should handle templates with only Handlebars helpers (no variables)', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const pagesDir = path.join(testTemplatesDir, 'pages'); + await fs.promises.mkdir(pagesDir, { recursive: true }); + + const helpersOnlyTemplate = ` +
+ {{#if true}} +

Always shown

+ {{/if}} + + {{#unless false}} +

Also always shown

+ {{/unless}} + + Static content +
`; + + await fs.promises.writeFile( + path.join(pagesDir, 'helpers-only.html'), + helpersOnlyTemplate, + ); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + // Should detect no data variables (only helpers) + // Note: May include some variables captured during form analysis + expect(Object.keys(results).length).toBeLessThanOrEqual(2); + }); + }); + + describe('Component Analysis', () => { + it('should capture form component variables with form context in second pass', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const formsDir = path.join(testTemplatesDir, 'components', 'common', 'forms'); + const optionsDir = path.join(testTemplatesDir, 'components', 'products', 'options'); + await fs.promises.mkdir(formsDir, { recursive: true }); + await fs.promises.mkdir(optionsDir, { recursive: true }); + + // Create form components with standalone variables + const textFieldTemplate = ` + +{{#if hasErrors}}{{errorText}}{{/if}}`; + + const selectFieldTemplate = ` +`; + + await fs.promises.writeFile(path.join(formsDir, 'text.html'), textFieldTemplate); + await fs.promises.writeFile(path.join(optionsDir, 'select.html'), selectFieldTemplate); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + // Verify form variables are captured with form prefix + expect(results['form.fieldName']).toBeDefined(); + expect(results['form.fieldValue']).toBeDefined(); + expect(results['form.isRequired']).toBeDefined(); + expect(results['form.isDisabled']).toBeDefined(); + expect(results['form.hasErrors']).toBeDefined(); + expect(results['form.errorText']).toBeDefined(); + expect(results['form.selectName']).toBeDefined(); + expect(results['form.optionsList']).toBeDefined(); + expect(results['form.optionValue']).toBeDefined(); + expect(results['form.isSelected']).toBeDefined(); + expect(results['form.optionLabel']).toBeDefined(); + + // Verify proper attribution to components + expect(results['form.fieldName'].paths).toEqual( + expect.arrayContaining(['components/common/forms/text.html']), + ); + expect(results['form.selectName'].paths).toEqual( + expect.arrayContaining(['components/products/options/select.html']), + ); + }); + + it('should not capture common form helper variables', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const formsDir = path.join(testTemplatesDir, 'components', 'common', 'forms'); + await fs.promises.mkdir(formsDir, { recursive: true }); + + const formWithHelpersTemplate = ` +
+ {{#each items}} +
+ {{this.name}} - {{@key}} + {{#if @first}}First{{/if}} + {{#if @last}}Last{{/if}} +
+ {{/each}} +
`; + + await fs.promises.writeFile(path.join(formsDir, 'list.html'), formWithHelpersTemplate); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + // Should capture real variables but not helper variables + expect(results['form.items']).toBeDefined(); + // Should not capture helper variables like @index, @key, etc. + expect(results['form.@index']).toBeUndefined(); + expect(results['form.@key']).toBeUndefined(); + expect(results['form.@first']).toBeUndefined(); + expect(results['form.@last']).toBeUndefined(); + expect(results['form.this']).toBeUndefined(); + }); + + it('should capture non-form component variables with component context in second pass', async () => { + const testTemplatesDir = path.join(tempDir, 'templates'); + const commonDir = path.join(testTemplatesDir, 'components', 'common'); + await fs.promises.mkdir(commonDir, { recursive: true }); + + // Create non-form components + const headerTemplate = ` +
+

{{siteName}}

+ + +
`; + + const footerTemplate = ` +`; + + await fs.promises.writeFile(path.join(commonDir, 'header.html'), headerTemplate); + await fs.promises.writeFile(path.join(commonDir, 'footer.html'), footerTemplate); + + const analyzer = new StencilContextAnalyzer(testTemplatesDir); + const results = await analyzer.analyzeTemplates(); + + // Verify non-form component variables are captured with component context + expect(results['component.siteName']).toBeDefined(); + expect(results['component.menuItems']).toBeDefined(); + expect(results['component.menuItems.url']).toBeDefined(); + expect(results['component.menuItems.title']).toBeDefined(); + expect(results['component.userName']).toBeDefined(); + expect(results['component.userRole']).toBeDefined(); + expect(results['component.copyrightYear']).toBeDefined(); + expect(results['component.companyName']).toBeDefined(); + expect(results['component.socialLinks']).toBeDefined(); + expect(results['component.socialLinks.url']).toBeDefined(); + expect(results['component.socialLinks.platform']).toBeDefined(); + + // Verify attribution to component files + expect(results['component.siteName'].paths).toContain('components/common/header.html'); + expect(results['component.copyrightYear'].paths).toContain( + 'components/common/footer.html', + ); + }); + }); + + describe('Integration with Bundle Process', () => { + it('should work with actual bundle-like template structure', async () => { + // Test with the actual mocked theme structure + const analyzer = new StencilContextAnalyzer(validTemplatesPath); + const results = await analyzer.analyzeTemplates(); + + // Verify it finds variables from page templates + expect(Object.keys(results).length).toBeGreaterThan(0); + + // Verify it handles page template references correctly + const paths = Object.values(results).flatMap((variable) => variable.paths); + expect(paths).toEqual(expect.arrayContaining([expect.stringMatching(/^pages\//)])); + + // Verify output format is ready for bundle inclusion + Object.values(results).forEach((variable) => { + expect(variable).toHaveProperty('count'); + expect(variable).toHaveProperty('paths'); + expect(variable.count).toBeGreaterThan(0); + expect(variable.paths.length).toBeGreaterThan(0); + }); + }); + + it('should provide consistent results across multiple runs', async () => { + const analyzer = new StencilContextAnalyzer(validTemplatesPath); + const results1 = await analyzer.analyzeTemplates(); + const results2 = await analyzer.analyzeTemplates(); + + // Results should be identical across multiple runs + expect(results1).toEqual(results2); + expect(Object.keys(results1)).toEqual(Object.keys(results2)); + }); + }); +}); diff --git a/lib/stencil-bundle.js b/lib/stencil-bundle.js index 6ea1b705..ee4c2838 100644 --- a/lib/stencil-bundle.js +++ b/lib/stencil-bundle.js @@ -10,6 +10,7 @@ import BundleValidator from './bundle-validator.js'; import Cycles from './Cycles.js'; import langAssembler from './lang-assembler.js'; import templateAssembler from './template-assembler.js'; +import StencilContextAnalyzer from './StencilContextAnalyzer.js'; import { recursiveReadDir } from './utils/fsUtils.js'; import { fetchRegions } from './regions.js'; @@ -65,6 +66,7 @@ class Bundle { tasks.lang = this.assembleLangTask.bind(this); tasks.schema = this.assembleSchema.bind(this); tasks.schemaTranslations = this.assembleSchemaTranslations.bind(this); + tasks.stencilContext = this.assembleStencilContextTask.bind(this); if (typeof buildConfigManager.production === 'function') { tasks.theme = (callback) => { console.log('Theme task Started...'); @@ -243,6 +245,19 @@ class Bundle { return schema; } + async assembleStencilContextTask() { + console.log('Stencil Context Analysis Started...'); + try { + const analyzer = new StencilContextAnalyzer(this.templatesPath); + const variableUsage = await analyzer.analyzeTemplates(); + console.log(`${'ok'.green} -- Stencil Context Analysis Finished`); + return variableUsage; + } catch (err) { + console.error('Stencil Context Analysis Failed:', err.message); + throw err; + } + } + assembleLangTask(callback) { console.log('Language Files Parsing Started...'); langAssembler.assemble((err, results) => { @@ -443,6 +458,10 @@ class Bundle { // append the parsed schemaTranslations.json file archiveJsonFile(result, 'schemaTranslations.json'); break; + case 'stencilContext': + // append the stencil context analysis file + archiveJsonFile(result, 'parsed/stencilContext.json'); + break; case 'manifest': // append the generated manifest.json file archiveJsonFile(result, 'manifest.json'); diff --git a/package-lock.json b/package-lock.json index 243a4b21..5c666337 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "stencil-push": "bin/stencil-push.js", "stencil-release": "bin/stencil-release.js", "stencil-scss-autofix": "bin/stencil-scss-autofix.js", + "stencil-attributes-analyzer": "bin/stencil-attributes-analyzer.js", "stencil-start": "bin/stencil-start.js" }, "devDependencies": { diff --git a/package.json b/package.json index aad0c179..0046f874 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "stencil-start": "./bin/stencil-start.js", "stencil-release": "./bin/stencil-release.js", "stencil-debug": "./bin/stencil-debug.js", - "stencil-scss-autofix": "./bin/stencil-scss-autofix.js" + "stencil-scss-autofix": "./bin/stencil-scss-autofix.js", + "stencil-attributes-analyzer": "./bin/stencil-attributes-analyzer.js" }, "config": { "stencil_version": "1.0"