Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ pnpm-lock.yaml

# Test run-time fixtures
test-collection
test-collection-b
test-theme
.shopify
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 21 additions & 12 deletions src/commands/theme/component/clean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -29,21 +31,28 @@ export default class Clean extends BaseCommand {
}

public async run(): Promise<void> {
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 });
}
}
}
Expand Down
73 changes: 54 additions & 19 deletions src/commands/theme/component/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -38,36 +39,70 @@ export default class Copy extends BaseCommand {

public async run(): Promise<void> {
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 });
}
}
}
}
Expand Down
16 changes: 10 additions & 6 deletions src/commands/theme/component/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
])

Expand All @@ -39,9 +40,12 @@ export default class Install extends BaseCommand {
}

public async run(): Promise<void> {
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'])
}
}
}
116 changes: 116 additions & 0 deletions src/commands/theme/component/manifest.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await super.init(Manifest)
}

public async run(): Promise<void> {
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))
}
}
Loading