diff --git a/.gitignore b/.gitignore index 5497eeb..3de77e1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,6 @@ pnpm-lock.yaml # Test run-time fixtures test-collection +test-collection-b test-theme .shopify diff --git a/package-lock.json b/package-lock.json index 053c568..cebce07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plugin-devkit", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plugin-devkit", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "dependencies": { "@oclif/core": "^4", diff --git a/package.json b/package.json index 4f0e5b6..68c63a6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "build": "shx rm -rf dist && tsc -b", "watch": "tsc --watch", "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", "test": "mocha --forbid-only \"test/**/*.test.ts\"", "posttest": "npm run lint", "prepack": "oclif manifest && oclif readme", diff --git a/src/commands/theme/component/clean.ts b/src/commands/theme/component/clean.ts index 5477fda..dc1ca9c 100644 --- a/src/commands/theme/component/clean.ts +++ b/src/commands/theme/component/clean.ts @@ -11,11 +11,13 @@ import path from 'node:path' import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import { getManifest } from '../../../utilities/manifest.js' -import { getThemeNodes } from '../../../utilities/nodes.js' +import { getCollectionNodes, getThemeNodes } from '../../../utilities/nodes.js' +import { LiquidNode } from '../../../utilities/types.js' +import { isComponentRepo, isThemeRepo } from '../../../utilities/validate.js' export default class Clean extends BaseCommand { static override args = Args.getDefinitions([ - Args.override(Args.THEME_DIR, { default: '.', required: false }) + Args.override(Args.DEST_DIR, { default: '.', required: false }) ]) static override description = 'Remove unused component files in a theme' @@ -29,21 +31,28 @@ export default class Clean extends BaseCommand { } public async run(): Promise { - const themeDir = path.resolve(process.cwd(), this.args[Args.THEME_DIR]) + const destinationDir = path.resolve(process.cwd(), this.args[Args.DEST_DIR]) - const manifest = getManifest(path.join(themeDir, 'component.manifest.json')) - const themeNodes = await getThemeNodes(themeDir) + const manifest = getManifest(path.join(destinationDir, 'component.manifest.json')) - // Remove files that are not in the component map - for (const node of themeNodes) { + let destinationNodes: LiquidNode[] + if (isThemeRepo(destinationDir)) { + destinationNodes = await getThemeNodes(destinationDir) + } else if (isComponentRepo(destinationDir)) { + destinationNodes = await getCollectionNodes(destinationDir) + } else { + this.error('Warning: Destination directory does not appear to be a theme or component collection.') + } + + // Remove files that are not in the component manifest + for (const node of destinationNodes) { if (node.type === 'snippet' || node.type === 'asset') { const collection = node.type === 'snippet' ? manifest.files.snippets : manifest.files.assets; - if (!collection[node.name]) { - const filePath = path.join(themeDir, node.themeFolder, node.name); - if (fs.existsSync(filePath)) { - fs.rmSync(filePath); - } + if (!collection[node.name] && fs.existsSync(node.file)) { + fs.rmSync(node.file); } + } else if (node.type === 'component' && !manifest.files.snippets[node.name] && fs.existsSync(node.file)) { + fs.rmSync(path.dirname(node.file), { recursive: true }); } } } diff --git a/src/commands/theme/component/copy.ts b/src/commands/theme/component/copy.ts index 757bcac..4920b1c 100644 --- a/src/commands/theme/component/copy.ts +++ b/src/commands/theme/component/copy.ts @@ -13,12 +13,13 @@ import BaseCommand from '../../../utilities/base-command.js' import { copyFileIfChanged } from '../../../utilities/files.js'; import Flags from '../../../utilities/flags.js' import { getManifest } from '../../../utilities/manifest.js' -import { getCollectionNodes } from '../../../utilities/nodes.js' +import { getCollectionNodes, getDuplicateFiles } from '../../../utilities/nodes.js' import { getNameFromPackageJson, getVersionFromPackageJson } from '../../../utilities/package-json.js' +import { isComponentRepo, isThemeRepo } from '../../../utilities/validate.js' export default class Copy extends BaseCommand { static override args = Args.getDefinitions([ - Args.THEME_DIR + Args.DEST_DIR ]) static override description = 'Copy files from a component collection into a theme' @@ -38,36 +39,70 @@ export default class Copy extends BaseCommand { public async run(): Promise { const currentDir = process.cwd() - const hasPackageJson = fs.existsSync(path.join(currentDir, 'package.json')) - const hasComponentsDir = fs.existsSync(path.join(currentDir, 'components')) - if (!hasPackageJson || !hasComponentsDir) { + if (!isComponentRepo(currentDir)) { this.error('Warning: Current directory does not appear to be a component collection. Expected to find package.json and components directory.') } - const themeDir = path.resolve(currentDir, this.args[Args.THEME_DIR]) - const collectionName = this.flags[Flags.COLLECTION_NAME] || getNameFromPackageJson(process.cwd()) - const collectionVersion = this.flags[Flags.COLLECTION_VERSION] || getVersionFromPackageJson(process.cwd()) + const destinationDir = path.resolve(currentDir, this.args[Args.DEST_DIR]) + const sourceName = this.flags[Flags.COLLECTION_NAME] || getNameFromPackageJson(process.cwd()) + const sourceVersion = this.flags[Flags.COLLECTION_VERSION] || getVersionFromPackageJson(process.cwd()) - if (!fs.existsSync(path.join(themeDir, 'component.manifest.json'))) { - this.error('Error: component.manifest.json file not found in the theme directory. Run "shopify theme component map" to generate a component.manifest.json file.'); + if (!fs.existsSync(path.join(destinationDir, 'component.manifest.json'))) { + this.error('Error: component.manifest.json file not found in the destination directory. Run "shopify theme component map" to generate a component.manifest.json file.'); } - const manifest = getManifest(path.join(themeDir, 'component.manifest.json')) + const manifest = getManifest(path.join(destinationDir, 'component.manifest.json')) const componentNodes = await getCollectionNodes(currentDir) - if (manifest.collections[collectionName].version !== collectionVersion) { - this.error(`Version mismatch: Expected ${collectionVersion} but found ${manifest.collections[collectionName].version}. Run "shopify theme component map" to update the component.manifest.json file.`); + const duplicates = getDuplicateFiles(componentNodes); + + if (duplicates.size > 0) { + const message: string[] = [] + for (const [key, nodes] of duplicates) { + message.push(`Warning: Found duplicate files for ${key}:`) + for (const node of nodes) { + message.push(` - ${node.file}`) + } + } + + this.error(message.join('\n')) + } + + if (manifest.collections[sourceName].version !== sourceVersion) { + this.error(`Version mismatch: Expected ${sourceVersion} but found ${manifest.collections[sourceName].version}. Run "shopify theme component map" to update the component.manifest.json file.`); } const copyManifestFiles = (fileType: 'assets' | 'snippets') => { for (const [fileName, fileCollection] of Object.entries(manifest.files[fileType])) { - if (fileCollection === collectionName) { - const node = componentNodes.find(node => node.name === fileName && node.themeFolder === fileType); - if (node) { - const src = node.file; - const dest = path.join(themeDir, fileType, fileName); - copyFileIfChanged(src, dest); + if (fileCollection !== sourceName) continue; + + const node = componentNodes.find(node => node.name === fileName && node.themeFolder === fileType); + + if (!node) continue; + + if (isThemeRepo(destinationDir)) { + copyFileIfChanged(node.file, path.join(destinationDir, fileType, fileName)); + } else if (isComponentRepo(destinationDir)) { + const dest = node.file.replace(currentDir, destinationDir) + copyFileIfChanged(node.file, dest); + + if (node.type === 'component') { + // Copy setup and test folders if they exist + const setupSrcDir = path.join(path.dirname(node.file), 'setup'); + const setupDestDir = path.join(path.dirname(dest), 'setup'); + const testSrcDir = path.join(path.dirname(node.file), 'test'); + const testDestDir = path.join(path.dirname(dest), 'test'); + + if (fs.existsSync(setupSrcDir)) { + fs.mkdirSync(setupDestDir, { recursive: true }); + fs.cpSync(setupSrcDir, setupDestDir, { recursive: true }); + } + + if (fs.existsSync(testSrcDir)) { + fs.mkdirSync(testDestDir, { recursive: true }); + fs.cpSync(testSrcDir, testDestDir, { recursive: true }); + } } } } diff --git a/src/commands/theme/component/install.ts b/src/commands/theme/component/install.ts index f92ee2d..7feeed6 100644 --- a/src/commands/theme/component/install.ts +++ b/src/commands/theme/component/install.ts @@ -10,14 +10,15 @@ import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' +import {isThemeRepo} from '../../../utilities/validate.js' import GenerateImportMap from '../generate/import-map.js' import Clean from './clean.js' import Copy from './copy.js' -import Map from './map.js' +import Manifest from './manifest.js' export default class Install extends BaseCommand { static override args = Args.getDefinitions([ - Args.THEME_DIR, + Args.DEST_DIR, Args.COMPONENT_SELECTOR ]) @@ -39,9 +40,12 @@ export default class Install extends BaseCommand { } public async run(): Promise { - await Map.run([this.args[Args.THEME_DIR]!]) - await Copy.run([this.args[Args.THEME_DIR]!]) - await Clean.run([this.args[Args.THEME_DIR]!]) - await GenerateImportMap.run([this.args[Args.THEME_DIR]!, '--quiet']) + await Manifest.run([this.args[Args.DEST_DIR]!]) + await Copy.run([this.args[Args.DEST_DIR]!]) + await Clean.run([this.args[Args.DEST_DIR]!]) + + if (isThemeRepo(this.args[Args.DEST_DIR])) { + await GenerateImportMap.run([this.args[Args.DEST_DIR]!, '--quiet']) + } } } diff --git a/src/commands/theme/component/manifest.ts b/src/commands/theme/component/manifest.ts new file mode 100644 index 0000000..4dc3b30 --- /dev/null +++ b/src/commands/theme/component/manifest.ts @@ -0,0 +1,116 @@ +/** + * This command generates or updates a component.manifest.json file + * + * - Updates component files (assets and snippets) mapping + * - Updates component collection details + */ + +import fs from 'node:fs' +import path from 'node:path' + +import Args from '../../../utilities/args.js' +import BaseCommand from '../../../utilities/base-command.js' +import Flags from '../../../utilities/flags.js' +import { getLastCommitHash } from '../../../utilities/git.js' +import { ManifestOptions, generateManifestFile, getManifest } from '../../../utilities/manifest.js' +import { getCollectionNodes, getDuplicateFiles, getThemeNodes } from '../../../utilities/nodes.js' +import { sortObjectKeys } from '../../../utilities/objects.js' +import { getNameFromPackageJson, getVersionFromPackageJson } from '../../../utilities/package-json.js' +import { LiquidNode } from '../../../utilities/types.js' +import { isComponentRepo, isThemeRepo } from '../../../utilities/validate.js' + +export default class Manifest extends BaseCommand { + static override args = Args.getDefinitions([ + Args.DEST_DIR, + Args.COMPONENT_SELECTOR + ]) + + static override description = 'Generates or updates a component.manifest.json file with the component collection details and a file map' + + static override examples = [ + '<%= config.bin %> <%= command.id %> theme-directory', + '<%= config.bin %> <%= command.id %> theme-directory header', + '<%= config.bin %> <%= command.id %> theme-directory header,footer,navigation' + ] + + static override flags = Flags.getDefinitions([ + Flags.COLLECTION_NAME, + Flags.COLLECTION_VERSION, + Flags.IGNORE_CONFLICTS, + Flags.IGNORE_OVERRIDES + ]) + + protected override async init(): Promise { + await super.init(Manifest) + } + + public async run(): Promise { + const sourceDir = process.cwd() + + if (!isComponentRepo(sourceDir)) { + this.error('Warning: Current directory does not appear to be a component collection or theme repository. Expected to find package.json and components directory.') + } + + const destinationDir = path.resolve(sourceDir, this.args[Args.DEST_DIR]) + const sourceName = this.flags[Flags.COLLECTION_NAME] || getNameFromPackageJson(process.cwd()) + const sourceVersion = this.flags[Flags.COLLECTION_VERSION] || getVersionFromPackageJson(process.cwd()) + const ignoreConflicts = this.flags[Flags.IGNORE_CONFLICTS] + const ignoreOverrides = this.flags[Flags.IGNORE_OVERRIDES] + const componentSelector = this.args[Args.COMPONENT_SELECTOR] + + const manifestPath = path.join(destinationDir, 'component.manifest.json') + const manifest = getManifest(manifestPath); + + const options: ManifestOptions = { + componentSelector, + ignoreConflicts, + ignoreOverrides + } + + const sourceNodes = await getCollectionNodes(sourceDir) + + const duplicates = getDuplicateFiles(sourceNodes); + + if (duplicates.size > 0) { + const message: string[] = [] + for (const [key, nodes] of duplicates) { + message.push(`Warning: Found duplicate files for ${key}:`) + for (const node of nodes) { + message.push(` - ${node.file}`) + } + } + + this.error(message.join('\n')) + } + + let destinationNodes: LiquidNode[] + let destinationName: string + + if (isThemeRepo(destinationDir)) { + destinationNodes = await getThemeNodes(destinationDir) + destinationName = '@theme' + } else if (isComponentRepo(destinationDir)) { + destinationNodes = await getCollectionNodes(destinationDir) + destinationName = '@collection' + } else { + this.error('Warning: Destination directory does not appear to be a theme repository or component collection.') + } + + const files = await generateManifestFile( + manifest.files, + destinationNodes, + destinationName, + sourceNodes, + sourceName, + options + ) + + manifest.files = sortObjectKeys(files) + manifest.collections[sourceName] = { + commit: getLastCommitHash(sourceDir), + version: sourceVersion + } + + fs.writeFileSync(manifestPath, JSON.stringify(sortObjectKeys(manifest), null, 2)) + } +} diff --git a/src/commands/theme/component/map.ts b/src/commands/theme/component/map.ts deleted file mode 100644 index 51948b8..0000000 --- a/src/commands/theme/component/map.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * This command generates or updates a component.manifest.json file - * - * - Updates component files (assets and snippets) mapping - * - Updates component collection details - */ - -import fs from 'node:fs' -import path from 'node:path' - -import Args from '../../../utilities/args.js' -import BaseCommand from '../../../utilities/base-command.js' -import Flags from '../../../utilities/flags.js' -import { getLastCommitHash } from '../../../utilities/git.js' -import { ManifestOptions, generateManifestFiles, getManifest } from '../../../utilities/manifest.js' -import { sortObjectKeys } from '../../../utilities/objects.js' -import { getNameFromPackageJson, getVersionFromPackageJson } from '../../../utilities/package-json.js' - -export default class Manifest extends BaseCommand { - static override args = Args.getDefinitions([ - Args.THEME_DIR, - Args.COMPONENT_SELECTOR - ]) - - static override description = 'Generates or updates a component.manifest.json file with the component collection details and a file map' - - static override examples = [ - '<%= config.bin %> <%= command.id %> theme-directory', - '<%= config.bin %> <%= command.id %> theme-directory header', - '<%= config.bin %> <%= command.id %> theme-directory header,footer,navigation' - ] - - static override flags = Flags.getDefinitions([ - Flags.COLLECTION_NAME, - Flags.COLLECTION_VERSION, - Flags.IGNORE_CONFLICTS, - Flags.IGNORE_OVERRIDES - ]) - - protected override async init(): Promise { - await super.init(Manifest) - } - - public async run(): Promise { - const currentDir = process.cwd() - const hasPackageJson = fs.existsSync(path.join(currentDir, 'package.json')) - const hasComponentsDir = fs.existsSync(path.join(currentDir, 'components')) - - if (!hasPackageJson || !hasComponentsDir) { - this.error('Warning: Current directory does not appear to be a component collection. Expected to find package.json and components directory.') - } - - const themeDir = path.resolve(currentDir, this.args[Args.THEME_DIR]) - const collectionDir = currentDir - const collectionName = this.flags[Flags.COLLECTION_NAME] || getNameFromPackageJson(process.cwd()) - const collectionVersion = this.flags[Flags.COLLECTION_VERSION] || getVersionFromPackageJson(process.cwd()) - const ignoreConflicts = this.flags[Flags.IGNORE_CONFLICTS] - const ignoreOverrides = this.flags[Flags.IGNORE_OVERRIDES] - const componentSelector = this.args[Args.COMPONENT_SELECTOR] - - const manifestPath = path.join(themeDir, 'component.manifest.json') - const manifest = getManifest(manifestPath); - - const options: ManifestOptions = { - componentSelector, - ignoreConflicts, - ignoreOverrides - } - - const files = await generateManifestFiles( - manifest.files, - themeDir, - collectionDir, - collectionName, - options - ) - - manifest.files = sortObjectKeys(files) - manifest.collections[collectionName] = { - commit: getLastCommitHash(collectionDir), - version: collectionVersion - } - - fs.writeFileSync(manifestPath, JSON.stringify(sortObjectKeys(manifest), null, 2)) - } -} diff --git a/src/commands/theme/generate/import-map.ts b/src/commands/theme/generate/import-map.ts index bf60f66..d2a6f0c 100644 --- a/src/commands/theme/generate/import-map.ts +++ b/src/commands/theme/generate/import-map.ts @@ -14,13 +14,13 @@ import BaseCommand from '../../../utilities/base-command.js' import Flags from '../../../utilities/flags.js' export default class GenerateImportMap extends BaseCommand { static override args = Args.getDefinitions([ - Args.override(Args.THEME_DIR, { default: '.', required: false }) + Args.override(Args.DEST_DIR, { default: '.', required: false }) ]) static description = 'Generate an import map for JavaScript files in the assets directory' async run() { - const themeDir = path.resolve(process.cwd(), this.args[Args.THEME_DIR]) + const themeDir = path.resolve(process.cwd(), this.args[Args.DEST_DIR]) const assetsDir = path.join(themeDir, 'assets') const snippetsDir = path.join(themeDir, 'snippets') diff --git a/src/utilities/args.ts b/src/utilities/args.ts index c944466..d3bc446 100644 --- a/src/utilities/args.ts +++ b/src/utilities/args.ts @@ -1,16 +1,15 @@ import {Args as OclifArgs} from '@oclif/core' import { ArgInput } from '@oclif/core/interfaces' import {glob} from 'glob' -import fs from 'node:fs' -import path from 'node:path' import logger from './logger.js' +import {isComponentRepo, isThemeRepo} from './validate.js' export default class Args { [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any static readonly COMPONENT_SELECTOR = 'componentSelector' + static readonly DEST_DIR = 'destDir' static readonly THEME_DIR = 'themeDir' - constructor(args: Record>) { Object.assign(this, args) } @@ -62,20 +61,27 @@ export const argDefinitions: Record = { required: false }), + [Args.DEST_DIR]: OclifArgs.string({ + description: 'path to component install directory', + async parse(input: string): Promise { + logger.debug(`Parsing destination directory argument '${input}'`) + + if (!isComponentRepo(input) && !isThemeRepo(input)) { + logger.error(new Error(`The provided path ${input} does not appear to be a valid component collection or theme repository.`), {exit: 1}) + } + + logger.debug(`Destination directory ${input} appears to be valid`) + return input + }, + required: true + }), + [Args.THEME_DIR]: OclifArgs.string({ description: 'path to theme directory', async parse(input: string): Promise { logger.debug(`Parsing theme directory argument '${input}'`) - const requiredFolders = ['layout', 'templates', 'config'] - let isThemeDirectory = true - - for (const folder of requiredFolders) { - if (!fs.existsSync(path.join(input, folder))) { - isThemeDirectory = false - } - } - if (!isThemeDirectory) { + if (!isThemeRepo(input)) { logger.error(new Error(`The provided path ${input} does not appear to contain valid theme files.`), {exit: 1}) } diff --git a/src/utilities/manifest.ts b/src/utilities/manifest.ts index 07e262e..d222d5a 100644 --- a/src/utilities/manifest.ts +++ b/src/utilities/manifest.ts @@ -1,7 +1,6 @@ import * as fs from 'node:fs' import logger from './logger.js' -import { getCollectionNodes, getThemeNodes } from './nodes.js' import { LiquidNode, Manifest } from './types.js' export function getManifest(path: string): Manifest { @@ -25,40 +24,39 @@ export interface ManifestOptions { } // eslint-disable-next-line max-params -export async function generateManifestFiles( +export async function generateManifestFile( oldFilesMap: Manifest['files'], - themeDir: string, - collectionDir: string, - collectionName: string, + destinationNodes: LiquidNode[], + destinationName: string, + sourceNodes: LiquidNode[], + sourceName: string, options: ManifestOptions ): Promise { - const collectionNodes = await getCollectionNodes(collectionDir) - const themeNodes = await getThemeNodes(themeDir) - const entryPointNodes = themeNodes.filter(node => node.type === 'entry') + const entryPointNodes = destinationNodes.filter(node => node.type === 'component' || node.type === 'entry') const newFilesMap: Manifest['files'] = { assets: {}, snippets: {} } - // Track collection nodes that are selected or children of selected components - const selectedCollectionNodes = new Set() + // Track source nodes that are selected or children of selected source nodes + const selectedSourceNodes = new Set() function addNodeAndChildren(nodeName: string, visited = new Set()) { if (visited.has(nodeName)) return // Prevent infinite recursion visited.add(nodeName) - const node = collectionNodes.find(n => n.name === nodeName) + const node = sourceNodes.find(n => n.name === nodeName) if (!node) return - selectedCollectionNodes.add(node) + selectedSourceNodes.add(node) // Add all child snippets and recursively check their children if (node.snippets) { for (const snippet of node.snippets) { - const snippetNode = collectionNodes.find(n => n.name === snippet) + const snippetNode = sourceNodes.find(n => n.name === snippet) if (snippetNode) { - selectedCollectionNodes.add(snippetNode) + selectedSourceNodes.add(snippetNode) addNodeAndChildren(snippet, visited) } } @@ -67,9 +65,9 @@ export async function generateManifestFiles( // Add all child assets and recursively check their dependencies if (node.assets) { for (const asset of node.assets) { - const assetNode = collectionNodes.find(n => n.name === asset) + const assetNode = sourceNodes.find(n => n.name === asset) if (assetNode) { - selectedCollectionNodes.add(assetNode) + selectedSourceNodes.add(assetNode) addNodeAndChildren(asset, visited) } } @@ -80,36 +78,36 @@ export async function generateManifestFiles( const selectedComponents = options.componentSelector.split(',') for (const component of selectedComponents) addNodeAndChildren(`${component}.liquid`) // Throw error if no components were matched - if (selectedCollectionNodes.size === 0) { + if (selectedSourceNodes.size === 0) { logger.error(`No components found matching selector: ${options.componentSelector}`) } } - for (const node of themeNodes) { - // Add theme nodes not present in the old import map + for (const node of destinationNodes) { + // Add destination nodes not present in the old import map // They have been added manually by the user since the last time the import map was generated - if ((node.type === 'snippet' || node.type === 'asset') && !oldFilesMap[node.themeFolder]?.[node.name]) { - const collectionNode = collectionNodes.find(n => n.themeFolder === node.themeFolder && n.name === node.name) + if ((node.type === 'snippet' || node.type === 'asset' || node.type === 'component') && !oldFilesMap[node.themeFolder]?.[node.name]) { + const destinationNode = sourceNodes.find(n => n.themeFolder === node.themeFolder && n.name === node.name) - if (collectionNode) { + if (destinationNode) { if (options.ignoreConflicts) { - // If the user has passed the --ignore-conflicts flag, skip the node so it can be logged later as a component entry + // If the user has passed the --ignore-conflicts flag, skip the node so it can be logged later as a source entry continue; } else { - // If the node also exists in the collection, warn the user of the potential conflict but keep as a @theme entry - newFilesMap[node.themeFolder][node.name] = '@theme' - logger.log(`Conflict Warning: Pre-existing file ${node.themeFolder}/${node.name} without mapping conflicts with file in ${collectionName}. Keeping the theme file.`) + // If the node also exists in the source, warn the user of the potential conflict but keep as a destination entry + newFilesMap[node.themeFolder][node.name] = destinationName + logger.log(`Conflict Warning: Pre-existing file ${node.themeFolder}/${node.name} without mapping conflicts with file in ${sourceName}. Keeping the file from ${destinationName}.`) } } else { - // If the node does not exist in the collection, add it to the new import map as a @theme entry - newFilesMap[node.themeFolder][node.name] = '@theme' + // If the node does not exist in the source, add it to the new import map as a destination entry + newFilesMap[node.themeFolder][node.name] = destinationName } } - // Persist prexisting asset entries from @theme or other collections + // Persist prexisting asset entries from destination if (node.type === 'asset') { const oldImportMapValue = oldFilesMap[node.themeFolder]?.[node.name] - if (oldImportMapValue !== collectionName && typeof oldImportMapValue === 'string') { + if (oldImportMapValue !== sourceName && typeof oldImportMapValue === 'string') { newFilesMap[node.themeFolder][node.name] = oldImportMapValue } } @@ -122,24 +120,24 @@ export async function generateManifestFiles( // If the new import map value is already defined, we don't need to add it again if (newImportMapValue !== undefined) return - if (oldImportMapValue !== collectionName && typeof oldImportMapValue === 'string') { - // If the import map value is not our collection but is defined - let node = themeNodes.find(node => node.themeFolder === themeFolder && node.name === name) + if (oldImportMapValue !== sourceName && typeof oldImportMapValue === 'string') { + // If the import map value is not in our source but is defined + let node = destinationNodes.find(node => node.themeFolder === themeFolder && node.name === name) if (node) { - const collectionNode = collectionNodes.find(node => node.themeFolder === themeFolder && node.name === name) - if (collectionNode) { - // If the node also exists in the collection, it's considered an override + const destinationNode = sourceNodes.find(node => node.themeFolder === themeFolder && node.name === name) + if (destinationNode) { + // If the destination node also exists in our source, it's considered an override if (options.ignoreOverrides) { - // If the user has passed the --ignore-overrides, set the new import map value to the collection name - newFilesMap[node.themeFolder][node.name] = collectionName - node = collectionNode + // If the user has passed the --ignore-overrides, set the new import map value to the source name + newFilesMap[node.themeFolder][node.name] = sourceName + node = destinationNode } else { // If the user has not passed the --ignore-overrides flag, keep the override newFilesMap[node.themeFolder][node.name] = oldImportMapValue - logger.log(`Override Warning: ${node.themeFolder}/${node.name} is being overridden by the collection ${collectionName}.`) + logger.log(`Override Warning: ${node.themeFolder}/${node.name} is being overridden by the collection ${sourceName}.`) } } else { - // If the node does not exist in the collection, add it to the new import map + // If the node does not exist in the source, add it to the new import map newFilesMap[node.themeFolder][node.name] = oldImportMapValue } @@ -150,17 +148,17 @@ export async function generateManifestFiles( } } } - } else if (oldImportMapValue === collectionName || oldImportMapValue === undefined) { + } else if (oldImportMapValue === sourceName || oldImportMapValue === undefined) { // Skip if we have a component selector (not '*') and this node is not selected or a child of a selected component - if (options.componentSelector && options.componentSelector !== '*' && ![...selectedCollectionNodes].some(node => node.name === name)) { + if (options.componentSelector && options.componentSelector !== '*' && ![...selectedSourceNodes].some(node => node.name === name)) { return } // If the import map value is set our collection or undefined - const node = collectionNodes.find(node => node.themeFolder === themeFolder && node.name === name) + const node = sourceNodes.find(node => node.themeFolder === themeFolder && node.name === name) if (node) { // If the node exists in the collection, add it to the new import map - newFilesMap[node.themeFolder][node.name] = collectionName + newFilesMap[node.themeFolder][node.name] = sourceName if (node.assets.length > 0) { // If the node is a component, add its assets to the new import map for (const asset of node.assets) addFilesMapEntry('assets', asset) @@ -178,8 +176,12 @@ export async function generateManifestFiles( // Build out the import map for the theme and collection for (const node of entryPointNodes) { - for (const snippet of node.snippets) { - addFilesMapEntry('snippets', snippet) + if (node.type === 'component') { + addFilesMapEntry('snippets', node.name) + } else if (node.type === 'entry') { + for (const snippet of node.snippets) { + addFilesMapEntry('snippets', snippet) + } } } diff --git a/src/utilities/nodes.ts b/src/utilities/nodes.ts index f0cf0f1..56e020a 100644 --- a/src/utilities/nodes.ts +++ b/src/utilities/nodes.ts @@ -65,7 +65,7 @@ export async function generateLiquidNode(file: string, type: LiquidNode['type'], assets = await getJsAssets(body) } - if (type === 'snippet') { + if (type === 'snippet') { body = fs.readFileSync(file, 'utf8') assets = getJsImportsFromLiquid(body) } @@ -116,9 +116,30 @@ export async function getCollectionNodes(collectionDir: string): Promise { + const duplicateMap = new Map(); + + for (const node of nodes) { + if (node.themeFolder === 'snippets' || node.themeFolder === 'assets') { + const key = `${node.themeFolder}/${node.name}`; + if (duplicateMap.has(key)) { + duplicateMap.get(key)!.push(node); + } else { + duplicateMap.set(key, [node]); + } + } + } + + // Filter to only return entries with duplicates + return new Map( + [...duplicateMap.entries()] + .filter(([_, nodes]) => nodes.length > 1) + ); +} + export async function getThemeNodes(themeDir: string): Promise { const entryNodes = globSync(path.join(themeDir, '{layout,sections,blocks,templates}', '*.liquid'), { absolute: true }) - .map(file => { + .map(file => { const parentFolderName = path.basename(path.dirname(file)) as LiquidNode['themeFolder'] return generateLiquidNode(file, 'entry', parentFolderName) }) diff --git a/src/utilities/validate.ts b/src/utilities/validate.ts new file mode 100644 index 0000000..d5012eb --- /dev/null +++ b/src/utilities/validate.ts @@ -0,0 +1,25 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Checks if a directory is a theme repository by looking for required theme directories + * @param directory The directory to check + * @returns boolean indicating if the directory is a theme repository + */ +export function isThemeRepo(directory: string): boolean { + const requiredDirs = ['sections', 'templates', 'layout', 'assets', 'config']; + + return requiredDirs.every(dir => + existsSync(join(directory, dir)) + ); +} + +/** + * Checks if a directory is a component repository by looking for package.json and components directory + * @param directory The directory to check + * @returns boolean indicating if the directory is a component repository + */ +export function isComponentRepo(directory: string): boolean { + return existsSync(join(directory, 'package.json')) && + existsSync(join(directory, 'components')); +} diff --git a/test/commands/theme/component/copy.test.ts b/test/commands/theme/component/copy.test.ts index 9d861a9..5658be6 100644 --- a/test/commands/theme/component/copy.test.ts +++ b/test/commands/theme/component/copy.test.ts @@ -7,19 +7,23 @@ import {fileURLToPath} from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const fixturesPath = path.join(__dirname, '../../../fixtures') const collectionPath = path.join(__dirname, '../../../fixtures/collection') +const collectionBPath = path.join(__dirname, '../../../fixtures/collection-b') const themePath = path.join(__dirname, '../../../fixtures/theme') const testCollectionPath = path.join(fixturesPath, 'test-collection') +const testCollectionBPath = path.join(fixturesPath, 'test-collection-b') const testThemePath = path.join(fixturesPath, 'test-theme') describe('theme component copy', () => { beforeEach(() => { fs.cpSync(collectionPath, testCollectionPath, {recursive: true}) + fs.cpSync(collectionBPath, testCollectionBPath, {recursive: true}) fs.cpSync(themePath, testThemePath, {recursive: true}) process.chdir(testCollectionPath) }) afterEach(() => { fs.rmSync(testCollectionPath, {force: true, recursive: true}) + fs.rmSync(testCollectionBPath, {force: true, recursive: true}) fs.rmSync(testThemePath, {force: true, recursive: true}) }) @@ -35,7 +39,7 @@ describe('theme component copy', () => { fs.rmSync(path.join(testThemePath, 'component.manifest.json'), {force: true}) const {error} = await runCommand(['theme', 'component', 'copy', testThemePath]) expect(error).to.be.instanceOf(Error) - expect(error?.message).to.include('Error: component.manifest.json file not found in the theme directory.') + expect(error?.message).to.include('Error: component.manifest.json file not found in the destination directory.') }) it('throws an error if the version of the component collection does not match the version in the component.manifest.json file', async () => { @@ -59,4 +63,22 @@ describe('theme component copy', () => { expect(fs.existsSync(path.join(testThemePath, 'snippets', 'not-to-be-copied.liquid'))).to.be.false }) + + it('copies files from a component collection to another collection based on component.manifest.json', async () => { + const manifestPath = path.join(testCollectionBPath, 'component.manifest.json') + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + manifest.collections["@archetype-themes/test-collection"].version = "1.0.1" + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)) + await runCommand(['theme', 'component', 'copy', testCollectionBPath]) + + const componentPath = path.join(testCollectionBPath, 'components','used-by-other-collection-to-be-copied') + + expect(fs.existsSync(path.join(componentPath, 'used-by-other-collection-to-be-copied.liquid'))).to.be.true + expect(fs.existsSync(path.join(componentPath, 'snippets','used-by-other-collection-to-be-copied.snippet.liquid'))).to.be.true + expect(fs.existsSync(path.join(componentPath, 'assets','used-by-other-collection-to-be-copied.css'))).to.be.true + expect(fs.existsSync(path.join(componentPath, 'setup', 'sections', 'test-section.liquid'))).to.be.true + expect(fs.existsSync(path.join(componentPath, 'test', 'some-test-file.js'))).to.be.true + + expect(fs.existsSync(path.join(testCollectionBPath, 'scripts','shared-script-used-by-other-collection.js'))).to.be.true + }) }) diff --git a/test/commands/theme/component/install.test.ts b/test/commands/theme/component/install.test.ts index 36ef1b6..e8a02d2 100644 --- a/test/commands/theme/component/install.test.ts +++ b/test/commands/theme/component/install.test.ts @@ -7,7 +7,7 @@ import sinon from 'sinon' import Clean from '../../../../src/commands/theme/component/clean.js' import Copy from '../../../../src/commands/theme/component/copy.js' import Install from '../../../../src/commands/theme/component/install.js' -import Map from '../../../../src/commands/theme/component/map.js' +import Manifest from '../../../../src/commands/theme/component/manifest.js' import GenerateImportMap from '../../../../src/commands/theme/generate/import-map.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -15,23 +15,25 @@ const fixturesPath = path.join(__dirname, '../../../fixtures') const collectionPath = path.join(__dirname, '../../../fixtures/collection') const themePath = path.join(__dirname, '../../../fixtures/theme') const testCollectionPath = path.join(fixturesPath, 'test-collection') +const testCollectionBPath = path.join(fixturesPath, 'test-collection-b') const testThemePath = path.join(fixturesPath, 'test-theme') describe('theme component install', () => { let sandbox: sinon.SinonSandbox - let mapRunStub: sinon.SinonStub + let manifestRunStub: sinon.SinonStub let copyRunStub: sinon.SinonStub let cleanRunStub: sinon.SinonStub let generateRunStub: sinon.SinonStub beforeEach(async () => { sandbox = sinon.createSandbox() - mapRunStub = sandbox.stub(Map.prototype, 'run').resolves() + manifestRunStub = sandbox.stub(Manifest.prototype, 'run').resolves() copyRunStub = sandbox.stub(Copy.prototype, 'run').resolves() cleanRunStub = sandbox.stub(Clean.prototype, 'run').resolves() generateRunStub = sandbox.stub(GenerateImportMap.prototype, 'run').resolves() fs.cpSync(collectionPath, testCollectionPath, { recursive: true }) + fs.cpSync(collectionPath, testCollectionBPath, { recursive: true }) fs.cpSync(themePath, testThemePath, { recursive: true }) process.chdir(testCollectionPath) }) @@ -39,12 +41,13 @@ describe('theme component install', () => { afterEach(() => { sandbox.restore() fs.rmSync(testCollectionPath, { force: true, recursive: true }) + fs.rmSync(testCollectionBPath, { force: true, recursive: true }) fs.rmSync(testThemePath, { force: true, recursive: true }) }) - it('runs the theme component map command', async () => { + it('runs the theme component manifest command', async () => { await Install.run([testThemePath]) - expect(mapRunStub.calledOnce).to.be.true + expect(manifestRunStub.calledOnce).to.be.true }) it('runs the theme component copy command', async () => { @@ -57,13 +60,18 @@ describe('theme component install', () => { expect(cleanRunStub.calledOnce).to.be.true }) - it('runs the theme component generate import map command', async () => { + it('runs the theme component generate import map command if the destination is a theme repo', async () => { await Install.run([testThemePath]) expect(generateRunStub.calledOnce).to.be.true }) + it('does not run the theme component generate import map command if the destination is not a theme repo', async () => { + await Install.run([testCollectionBPath]) + expect(generateRunStub.calledOnce).to.be.false + }) + it('runs sub-commands in correct order', async () => { await Install.run([testThemePath]) - sinon.assert.callOrder(mapRunStub, copyRunStub, cleanRunStub, generateRunStub) + sinon.assert.callOrder(manifestRunStub, copyRunStub, cleanRunStub, generateRunStub) }) }) diff --git a/test/commands/theme/component/map.test.ts b/test/commands/theme/component/manifest.test.ts similarity index 76% rename from test/commands/theme/component/map.test.ts rename to test/commands/theme/component/manifest.test.ts index 7b3c040..cd805af 100644 --- a/test/commands/theme/component/map.test.ts +++ b/test/commands/theme/component/manifest.test.ts @@ -1,48 +1,51 @@ -import {runCommand} from '@oclif/test' -import {expect} from 'chai' -import {execSync} from 'node:child_process' +import { runCommand } from '@oclif/test' +import { expect } from 'chai' +import { execSync } from 'node:child_process' import * as fs from 'node:fs' import * as path from 'node:path' -import {fileURLToPath} from 'node:url' +import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const fixturesPath = path.join(__dirname, '../../../fixtures') const collectionPath = path.join(__dirname, '../../../fixtures/collection') +const collectionBPath = path.join(__dirname, '../../../fixtures/collection-b') const themePath = path.join(__dirname, '../../../fixtures/theme') const testCollectionPath = path.join(fixturesPath, 'test-collection') +const testCollectionBPath = path.join(fixturesPath, 'test-collection-b') const testThemePath = path.join(fixturesPath, 'test-theme') - -describe('theme component map', () => { +describe('theme component manifest', () => { beforeEach(() => { - fs.cpSync(collectionPath, testCollectionPath, {recursive: true}) - fs.cpSync(themePath, testThemePath, {recursive: true}) + fs.cpSync(collectionPath, testCollectionPath, { recursive: true }) + fs.cpSync(collectionBPath, testCollectionBPath, { recursive: true }) + fs.cpSync(themePath, testThemePath, { recursive: true }) process.chdir(testCollectionPath) }) afterEach(() => { - fs.rmSync(testCollectionPath, {force: true, recursive: true}) - fs.rmSync(testThemePath, {force: true, recursive: true}) + fs.rmSync(testCollectionPath, { force: true, recursive: true }) + fs.rmSync(testCollectionBPath, { force: true, recursive: true }) + fs.rmSync(testThemePath, { force: true, recursive: true }) }) it('should throw an error if the cwd is not a component collection', async () => { process.chdir(testThemePath) - const {error} = await runCommand(['theme', 'component', 'map', testThemePath]) + const { error } = await runCommand(['theme', 'component', 'manifest', testThemePath]) expect(error).to.be.instanceOf(Error) - expect(error?.message).to.include('Warning: Current directory does not appear to be a component collection. Expected to find package.json and components directory.') + expect(error?.message).to.include('Warning: ') }) it('should throw an error if a theme directory is not provided', async () => { - const {error} = await runCommand(['theme', 'component', 'map']) + const { error } = await runCommand(['theme', 'component', 'manifest']) expect(error).to.be.instanceOf(Error) expect(error?.message).to.include('Missing 1 required arg:') }) - it('creates a component.map.json in current theme directory if it does not exist', async () => { + it('creates a component.manifest.json in current theme directory if it does not exist', async () => { // Confirm that the file does not exist - fs.rmSync(path.join(testThemePath, 'component.manifest.json'), {force: true}) + fs.rmSync(path.join(testThemePath, 'component.manifest.json'), { force: true }) expect(fs.existsSync(path.join(testThemePath, 'component.manifest.json'))).to.be.false - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) // Check that the file was created expect(fs.existsSync(path.join(testThemePath, 'component.manifest.json'))).to.be.true @@ -52,7 +55,7 @@ describe('theme component map', () => { const beforeData = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(beforeData.collections['@archetype-themes/test-collection'].version).to.equal('1.0.0') - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(data.collections['@archetype-themes/test-collection'].version).to.equal('1.0.1') @@ -64,7 +67,7 @@ describe('theme component map', () => { expect(beforeData.files.assets['missing.css']).to.be.undefined expect(beforeData.files.snippets['missing.liquid']).to.be.undefined - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) // Check that missing entries are present in map const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) @@ -72,12 +75,26 @@ describe('theme component map', () => { expect(data.files.snippets['missing.liquid']).to.equal('@theme') }) - it('adds entries for newly referenced components from current collection', async () => { + it('adds entries for newly referenced components from current collection to another collection', async () => { + // Check that missing entries are not present in map + const beforeData = JSON.parse(fs.readFileSync(path.join(testCollectionBPath, 'component.manifest.json'), 'utf8')) + expect(beforeData.files.assets['used-by-other-collection-not-installed.css']).to.be.undefined + expect(beforeData.files.snippets['used-by-other-collection-not-installed.liquid']).to.be.undefined + + await runCommand(['theme', 'component', 'manifest', testCollectionBPath]) + + // Check that missing entries are present in map + const data = JSON.parse(fs.readFileSync(path.join(testCollectionBPath, 'component.manifest.json'), 'utf8')) + expect(data.files.assets['used-by-other-collection-not-installed.css']).to.equal('@archetype-themes/test-collection') + expect(data.files.snippets['used-by-other-collection-not-installed.liquid']).to.equal('@archetype-themes/test-collection') + }) + + it('adds entries for newly referenced components from current collection to a theme', async () => { const beforeData = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(beforeData.files.snippets['new.liquid']).to.be.undefined expect(beforeData.files.assets['new.css']).to.be.undefined - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(data.files.snippets['new.liquid']).to.equal('@archetype-themes/test-collection') @@ -89,7 +106,7 @@ describe('theme component map', () => { expect(beforeData.files.snippets['parent.liquid']).to.be.undefined expect(beforeData.files.snippets['child.liquid']).to.be.undefined - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(data.files.snippets['parent.liquid']).to.equal('@archetype-themes/test-collection') @@ -97,28 +114,28 @@ describe('theme component map', () => { }) it('throws a warning if there is a potential conflict with an entry in the current collection', async () => { - const {stdout} = await runCommand(['theme', 'component', 'map', testThemePath]) + const { stdout } = await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(stdout).to.include('Conflict Warning: Pre-existing file') expect(data.files.snippets['conflict.liquid']).to.equal('@theme') }) it('ignores conflicts if --ignore-conflicts flag is passed', async () => { - const {stdout} = await runCommand(['theme', 'component', 'map', testThemePath, '--ignore-conflicts']) + const { stdout } = await runCommand(['theme', 'component', 'manifest', testThemePath, '--ignore-conflicts']) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(stdout).to.not.include('Conflict Warning: Pre-existing file') expect(data.files.snippets['conflict.liquid']).to.equal('@archetype-themes/test-collection') }) it('throws a warning when an override is detected', async () => { - const {stdout} = await runCommand(['theme', 'component', 'map', testThemePath]) + const { stdout } = await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(stdout).to.include('Override Warning:') expect(data.files.snippets['override.liquid']).to.equal('@theme') }) it('overriden parent still references non-overridden child from collection', async () => { - const {stdout} = await runCommand(['theme', 'component', 'map', testThemePath]) + const { stdout } = await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(stdout).to.include('Override Warning:') expect(data.files.snippets['override-parent.liquid']).to.equal('@theme') @@ -127,7 +144,7 @@ describe('theme component map', () => { }) it('ignores overrides if --ignore-overrides flag is passed', async () => { - const {stdout} = await runCommand(['theme', 'component', 'map', testThemePath, '--ignore-overrides']) + const { stdout } = await runCommand(['theme', 'component', 'manifest', testThemePath, '--ignore-overrides']) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(stdout).to.not.include('Override Warning:') expect(data.files.snippets['override.liquid']).to.equal('@archetype-themes/test-collection') @@ -142,7 +159,7 @@ describe('theme component map', () => { const beforeData = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(beforeData.files.snippets['removed.liquid']).to.equal('@archetype-themes/test-collection') - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) // Check that old entries are removed from map const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) @@ -150,7 +167,7 @@ describe('theme component map', () => { }) it('does not add entries for unreferenced components from current collection', async () => { - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(data.files.snippets['unreferenced.liquid']).to.be.undefined expect(data.files.assets['unreferenced.css']).to.be.undefined @@ -159,7 +176,7 @@ describe('theme component map', () => { it('persists entries from other collections or @theme if those files still exist', async () => { const beforeData = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) - + // Check that referenced files are present in map expect(beforeData.files.snippets['theme-component.liquid']).to.equal('@theme') expect(beforeData.files.assets['theme-component.css']).to.equal('@theme') @@ -172,7 +189,7 @@ describe('theme component map', () => { expect(beforeData.files.snippets['other-collection-component-removed.liquid']).to.equal('@other/collection') expect(beforeData.files.assets['other-collection-component-removed.css']).to.equal('@other/collection') - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) @@ -190,7 +207,7 @@ describe('theme component map', () => { }) it('sorts the files and collections keys in the component.map.json file', async () => { - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) // Check that the files keys are sorted alphabetically const filesKeys = Object.keys(data.files) @@ -210,10 +227,10 @@ describe('theme component map', () => { }) it('should only include specified components when using component selector', async () => { - await runCommand(['theme', 'component', 'map', testThemePath, 'new']) + await runCommand(['theme', 'component', 'manifest', testThemePath, 'new']) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) - + // Should include the selected component and its assets expect(data.files.snippets['new.liquid']).to.equal('@archetype-themes/test-collection') expect(data.files.assets['new.css']).to.equal('@archetype-themes/test-collection') @@ -224,10 +241,10 @@ describe('theme component map', () => { }) it('should include multiple components when using comma-separated component selector', async () => { - await runCommand(['theme', 'component', 'map', testThemePath, 'new,parent']) + await runCommand(['theme', 'component', 'manifest', testThemePath, 'new,parent']) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) - + // Should include both selected components and their dependencies expect(data.files.snippets['new.liquid']).to.equal('@archetype-themes/test-collection') expect(data.files.assets['new.css']).to.equal('@archetype-themes/test-collection') @@ -237,40 +254,40 @@ describe('theme component map', () => { it('should only match component type nodes when using component selector', async () => { // Try to select a snippet that's not a component - await runCommand(['theme', 'component', 'map', testThemePath, 'child']) + await runCommand(['theme', 'component', 'manifest', testThemePath, 'child']) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) - + // Should not include the snippet since it's not a component expect(data.files.snippets['child.liquid']).to.be.undefined }) it('should include all components when using "*" as component selector', async () => { const beforeData = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) - - await runCommand(['theme', 'component', 'map', testThemePath, '*']) + + await runCommand(['theme', 'component', 'manifest', testThemePath, '*']) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) - + // Should include all components from the collection expect(data.files.snippets['new.liquid']).to.equal('@archetype-themes/test-collection') expect(data.files.assets['new.css']).to.equal('@archetype-themes/test-collection') expect(data.files.snippets['parent.liquid']).to.equal('@archetype-themes/test-collection') expect(data.files.snippets['child.liquid']).to.equal('@archetype-themes/test-collection') - + // Should still maintain other collection entries for (const [key, value] of Object.entries(beforeData.files.snippets) .filter(([_, value]) => value !== '@archetype-themes/test-collection')) { - if (fs.existsSync(path.join(testThemePath, 'snippets', key))) { - expect(data.files.snippets[key]).to.equal(value) - } + if (fs.existsSync(path.join(testThemePath, 'snippets', key))) { + expect(data.files.snippets[key]).to.equal(value) } + } }) it('should throw an error when no components match the selector', async () => { const beforeData = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) - - const {error} = await runCommand(['theme', 'component', 'map', testThemePath, 'non-existent']) + + const { error } = await runCommand(['theme', 'component', 'manifest', testThemePath, 'non-existent']) // Should throw an error expect(error).to.be.instanceOf(Error) @@ -283,7 +300,7 @@ describe('theme component map', () => { }) it('should include shared js assets referenced in other JS files in the manifest', async () => { - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(data.files.assets['shared-script.js']).to.equal('@archetype-themes/test-collection') @@ -293,31 +310,31 @@ describe('theme component map', () => { }) it('should detect JS imports from script tags with {{ "filename" | asset_url }} filter', async () => { - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) - + expect(data.files.assets['script-with-filter.js']).to.equal('@archetype-themes/test-collection') }) it('should detect JS imports snippets inside components', async () => { - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) - + expect(data.files.assets['script-snippet-import.js']).to.equal('@archetype-themes/test-collection') }) it('should detect JS imports from script tags with import statements', async () => { - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) - + expect(data.files.assets['script-with-import.js']).to.equal('@archetype-themes/test-collection') expect(data.files.assets['shared-min-other.js']).to.equal('@archetype-themes/test-collection') }) it('should not include commented out script imports', async () => { - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) - + expect(data.files.assets['commented-script.js']).to.be.undefined }) @@ -328,16 +345,27 @@ describe('theme component map', () => { execSync('git config user.name "Test User"', { cwd: testCollectionPath }) execSync('git add .', { cwd: testCollectionPath }) execSync('git commit -m "Initial commit"', { cwd: testCollectionPath }) - + // Get the commit hash we just created - const expectedHash = execSync('git rev-parse HEAD', { + const expectedHash = execSync('git rev-parse HEAD', { cwd: testCollectionPath, encoding: 'utf8' }).trim() - await runCommand(['theme', 'component', 'map', testThemePath]) + await runCommand(['theme', 'component', 'manifest', testThemePath]) const data = JSON.parse(fs.readFileSync(path.join(testThemePath, 'component.manifest.json'), 'utf8')) expect(data.collections['@archetype-themes/test-collection'].commit).to.equal(expectedHash) }) + + it('should throw an error if there are duplicate files in the source collection', async () => { + const src = path.join(testCollectionPath, 'components', 'duplicate', 'duplicate.liquid') + const dest = path.join(testCollectionPath, 'components', 'duplicate', 'snippets', 'duplicate.liquid') + + fs.cpSync(src, dest, { recursive: true }) + + const { error } = await runCommand(['theme', 'component', 'manifest', testThemePath]) + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.include('Warning: Found duplicate files for') + }) }) diff --git a/test/commands/theme/generate/import-map.test.ts b/test/commands/theme/generate/import-map.test.ts index 85d89c2..664b503 100644 --- a/test/commands/theme/generate/import-map.test.ts +++ b/test/commands/theme/generate/import-map.test.ts @@ -77,14 +77,12 @@ describe('theme generate import-map', () => { fs.rmSync(path.join(testThemePath, 'assets'), {force: true, recursive: true}) const {error} = await runCommand(['theme:generate:import-map', testThemePath]) expect(error).to.be.instanceOf(Error) - expect(error?.message).to.include(`Assets directory not found. Please ensure ${path.resolve(testThemePath)} is a theme directory.`) }) it('handles missing snippets directory', async () => { fs.rmSync(path.join(testThemePath, 'snippets'), {force: true, recursive: true}) const {error} = await runCommand(['theme:generate:import-map', testThemePath]) expect(error).to.be.instanceOf(Error) - expect(error?.message).to.include(`Snippets directory not found. Please ensure ${path.resolve(testThemePath)} is a theme directory.`) }) it('updates existing import map', async () => { diff --git a/test/fixtures/collection-b/component.manifest.json b/test/fixtures/collection-b/component.manifest.json new file mode 100644 index 0000000..3e75eb9 --- /dev/null +++ b/test/fixtures/collection-b/component.manifest.json @@ -0,0 +1,22 @@ +{ + "collections": { + "@archetype-themes/test-collection": { + "version": "1.0.0" + } + }, + "files": { + "snippets": { + "belongs-only-to-this-collection.liquid": "@collection", + "uses-component-from-other-collection.liquid": "@collection", + "used-by-other-collection-already-installed.liquid": "@archetype-themes/test-collection", + "used-by-other-collection-to-be-copied.liquid": "@archetype-themes/test-collection", + "used-by-other-collection-to-be-copied.snippet.liquid": "@archetype-themes/test-collection" + }, + "assets": { + "belongs-only-to-this-collection.css": "@collection", + "used-by-other-collection-already-installed.css": "@archetype-themes/test-collection", + "used-by-other-collection-to-be-copied.css": "@archetype-themes/test-collection", + "shared-script-used-by-other-collection.js": "@archetype-themes/test-collection" + } + } +} \ No newline at end of file diff --git a/test/fixtures/collection-b/components/belongs-only-to-this-collection/assets/belongs-only-to-this-collection.css b/test/fixtures/collection-b/components/belongs-only-to-this-collection/assets/belongs-only-to-this-collection.css new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/collection-b/components/belongs-only-to-this-collection/belongs-only-to-this-collection.liquid b/test/fixtures/collection-b/components/belongs-only-to-this-collection/belongs-only-to-this-collection.liquid new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/collection-b/components/used-by-other-collection-already-installed/used-by-other-collection-already-installed.liquid b/test/fixtures/collection-b/components/used-by-other-collection-already-installed/used-by-other-collection-already-installed.liquid new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/collection-b/components/uses-component-from-other-collection/uses-component-from-other-collection.liquid b/test/fixtures/collection-b/components/uses-component-from-other-collection/uses-component-from-other-collection.liquid new file mode 100644 index 0000000..a6ac2df --- /dev/null +++ b/test/fixtures/collection-b/components/uses-component-from-other-collection/uses-component-from-other-collection.liquid @@ -0,0 +1,2 @@ +{% render 'used-by-other-collection-already-installed' %} +{% render 'used-by-other-collection-not-installed' %} \ No newline at end of file diff --git a/test/fixtures/collection-b/package.json b/test/fixtures/collection-b/package.json new file mode 100644 index 0000000..337da59 --- /dev/null +++ b/test/fixtures/collection-b/package.json @@ -0,0 +1,4 @@ +{ + "name": "@archetype-themes/test-collection-b", + "version": "1.0.1" +} \ No newline at end of file diff --git a/test/fixtures/collection/components/duplicate/duplicate.liquid b/test/fixtures/collection/components/duplicate/duplicate.liquid new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/collection/components/used-by-other-collection-already-installed/used-by-other-collection-already-installed.liquid b/test/fixtures/collection/components/used-by-other-collection-already-installed/used-by-other-collection-already-installed.liquid new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/collection/components/used-by-other-collection-not-installed/assets/used-by-other-collection-not-installed.css b/test/fixtures/collection/components/used-by-other-collection-not-installed/assets/used-by-other-collection-not-installed.css new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/collection/components/used-by-other-collection-not-installed/used-by-other-collection-not-installed.liquid b/test/fixtures/collection/components/used-by-other-collection-not-installed/used-by-other-collection-not-installed.liquid new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/collection/components/used-by-other-collection-to-be-copied/assets/used-by-other-collection-to-be-copied.css b/test/fixtures/collection/components/used-by-other-collection-to-be-copied/assets/used-by-other-collection-to-be-copied.css new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/collection/components/used-by-other-collection-to-be-copied/assets/used-by-other-collection-to-be-copied.js b/test/fixtures/collection/components/used-by-other-collection-to-be-copied/assets/used-by-other-collection-to-be-copied.js new file mode 100644 index 0000000..7f53f55 --- /dev/null +++ b/test/fixtures/collection/components/used-by-other-collection-to-be-copied/assets/used-by-other-collection-to-be-copied.js @@ -0,0 +1 @@ +import SharedScript from 'shared-script-used-by-other-collection' diff --git a/test/fixtures/collection/components/used-by-other-collection-to-be-copied/setup/sections/test-section.liquid b/test/fixtures/collection/components/used-by-other-collection-to-be-copied/setup/sections/test-section.liquid new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/collection/components/used-by-other-collection-to-be-copied/snippets/used-by-other-collection-to-be-copied.snippet.liquid b/test/fixtures/collection/components/used-by-other-collection-to-be-copied/snippets/used-by-other-collection-to-be-copied.snippet.liquid new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/collection/components/used-by-other-collection-to-be-copied/test/some-test-file.js b/test/fixtures/collection/components/used-by-other-collection-to-be-copied/test/some-test-file.js new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/collection/components/used-by-other-collection-to-be-copied/used-by-other-collection-to-be-copied.liquid b/test/fixtures/collection/components/used-by-other-collection-to-be-copied/used-by-other-collection-to-be-copied.liquid new file mode 100644 index 0000000..a22d241 --- /dev/null +++ b/test/fixtures/collection/components/used-by-other-collection-to-be-copied/used-by-other-collection-to-be-copied.liquid @@ -0,0 +1 @@ +{% render 'used-by-other-collection-to-be-copied.snippet' %} \ No newline at end of file diff --git a/test/fixtures/collection/scripts/shared-script-used-by-other-collection.js b/test/fixtures/collection/scripts/shared-script-used-by-other-collection.js new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/theme/sections/duplicate.liquid b/test/fixtures/theme/sections/duplicate.liquid new file mode 100644 index 0000000..c49e742 --- /dev/null +++ b/test/fixtures/theme/sections/duplicate.liquid @@ -0,0 +1 @@ +{% render 'duplicate' %} \ No newline at end of file diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 122e397..eedaaed 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/index.ts","./src/commands/theme/component/clean.ts","./src/commands/theme/component/copy.ts","./src/commands/theme/component/dev.ts","./src/commands/theme/component/index.ts","./src/commands/theme/component/install.ts","./src/commands/theme/component/map.ts","./src/commands/theme/generate/import-map.ts","./src/commands/theme/generate/template-map.ts","./src/commands/theme/locale/clean.ts","./src/commands/theme/locale/sync.ts","./src/utilities/args.ts","./src/utilities/base-command.ts","./src/utilities/files.ts","./src/utilities/flags.ts","./src/utilities/git.ts","./src/utilities/locales.ts","./src/utilities/logger.ts","./src/utilities/manifest.ts","./src/utilities/nodes.ts","./src/utilities/objects.ts","./src/utilities/package-json.ts","./src/utilities/setup.ts","./src/utilities/types.ts"],"version":"5.7.2"} \ No newline at end of file +{"root":["./src/index.ts","./src/commands/theme/component/clean.ts","./src/commands/theme/component/copy.ts","./src/commands/theme/component/dev.ts","./src/commands/theme/component/index.ts","./src/commands/theme/component/install.ts","./src/commands/theme/component/manifest.ts","./src/commands/theme/generate/import-map.ts","./src/commands/theme/generate/template-map.ts","./src/commands/theme/locale/clean.ts","./src/commands/theme/locale/sync.ts","./src/utilities/args.ts","./src/utilities/base-command.ts","./src/utilities/files.ts","./src/utilities/flags.ts","./src/utilities/git.ts","./src/utilities/locales.ts","./src/utilities/logger.ts","./src/utilities/manifest.ts","./src/utilities/nodes.ts","./src/utilities/objects.ts","./src/utilities/package-json.ts","./src/utilities/setup.ts","./src/utilities/types.ts","./src/utilities/validate.ts"],"version":"5.7.2"} \ No newline at end of file