diff --git a/src/commands/theme/component/copy.ts b/src/commands/theme/component/copy.ts index d3971869..f426b81a 100644 --- a/src/commands/theme/component/copy.ts +++ b/src/commands/theme/component/copy.ts @@ -1,6 +1,6 @@ /** * This command copies component files into a theme directory. - * + * * - Copies rendered component files (snippets and assets) into the theme directory * - Updates the theme CLI config (shopify.theme.json) with the component collection details */ @@ -8,13 +8,14 @@ import fs from 'node:fs' import path from 'node:path' -import Args from '../../../utilities/args.js' +import Args from '../../../utilities/args.js' import BaseCommand from '../../../utilities/base-command.js' -import { copyFileIfChanged } from '../../../utilities/files.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 { getNameFromPackageJson , getVersionFromPackageJson } from '../../../utilities/package-json.js' +import { getCopyrightConfigFromPackageJson, getNameFromPackageJson, getVersionFromPackageJson } from '../../../utilities/package-json.js' +import { CopyrightOptions } from '../../../utilities/types.js' export default class Copy extends BaseCommand { static override args = Args.getDefinitions([ @@ -49,10 +50,12 @@ export default class Copy extends BaseCommand { const collectionName = this.flags[Flags.COLLECTION_NAME] || getNameFromPackageJson(process.cwd()) const collectionVersion = this.flags[Flags.COLLECTION_VERSION] || getVersionFromPackageJson(process.cwd()) + const copyright = getCopyrightConfigFromPackageJson(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.'); } - + const manifest = getManifest(path.join(themeDir, 'component.manifest.json')) const componentNodes = await getCollectionNodes(currentDir) @@ -60,6 +63,12 @@ export default class Copy extends BaseCommand { 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 copyOptions: CopyrightOptions = { + collectionName, + collectionVersion, + copyright + }; + const copyManifestFiles = (fileType: 'assets' | 'snippets') => { for (const [fileName, fileCollection] of Object.entries(manifest.files[fileType])) { if (fileCollection === collectionName) { @@ -67,7 +76,7 @@ export default class Copy extends BaseCommand { if (node) { const src = node.file; const dest = path.join(themeDir, fileType, fileName); - copyFileIfChanged(src, dest); + copyFileIfChanged(src, dest, copyOptions); } } } diff --git a/src/utilities/copyright.ts b/src/utilities/copyright.ts new file mode 100644 index 00000000..e722a8ca --- /dev/null +++ b/src/utilities/copyright.ts @@ -0,0 +1,74 @@ +import path from 'node:path' + +import { CopyrightOptions } from './types.js' + +export const COPYRIGHT_TEMPLATES: Record string> = { + css: (message: string) => `/* ${message} */\n\n`, + js: (message: string) => `// ${message}\n\n`, + liquid: (message: string) => `{% # ${message} %}\n\n`, + svg: (message: string) => `\n\n` +} + +export function generateCopyrightMessage( + collectionName: string, + collectionVersion: string, + copyright?: CopyrightOptions['copyright'] +): string { + const year = new Date().getFullYear(); + const author = copyright?.author || ''; + const license = copyright?.license || ''; + + return `${collectionName} v${collectionVersion} | Copyright © ${year}` + + (author && ` ${author}`) + + (license && ` | ${license}`); +} + +export function shouldAddCopyright(filePath: string): boolean { + // Skip vendor files + if (path.basename(filePath).startsWith('vendor.')) { + return false; + } + + const fileExtension = path.extname(filePath).toLowerCase().slice(1); + + // Only add copyright to supported file types + return Object.keys(COPYRIGHT_TEMPLATES).includes(fileExtension); +} + +export function addCopyrightComment( + content: string, + filePath: string, + options: { + collectionName: string; + collectionVersion: string; + } & Partial +): string { + if (!shouldAddCopyright(filePath)) { + return content; + } + + const { collectionName, collectionVersion, copyright } = options; + const fileExtension = path.extname(filePath).toLowerCase().slice(1); + + // Check if the copyright comment is already present + if (content.includes(`${collectionName} v${collectionVersion}`)) { + return content; + } + + const copyrightMessage = generateCopyrightMessage( + collectionName, + collectionVersion, + copyright + ); + + const commentWrapper = COPYRIGHT_TEMPLATES[fileExtension]; + const copyrightComment = commentWrapper(copyrightMessage); + + // For SVG files, we need to insert the comment after the XML declaration if present + if (fileExtension === 'svg' && content.startsWith('') + 2; + return content.slice(0, xmlEndIndex) + '\n' + copyrightComment + content.slice(xmlEndIndex); + } + + return copyrightComment + content; +} diff --git a/src/utilities/files.ts b/src/utilities/files.ts index a5b77be9..0f17d797 100644 --- a/src/utilities/files.ts +++ b/src/utilities/files.ts @@ -2,7 +2,9 @@ import fs from 'fs-extra'; import path from 'node:path' +import { addCopyrightComment } from './copyright.js' import logger from './logger.js' +import { CopyrightOptions } from './types.js' export function syncFiles(srcDir: string, destDir: string) { if (!fs.existsSync(srcDir)) { @@ -32,7 +34,7 @@ export function syncFiles(srcDir: string, destDir: string) { } } } - + // Copy each file/directory from source for (const file of srcFiles) { if (file.startsWith('.')) continue; @@ -62,14 +64,28 @@ export function syncFiles(srcDir: string, destDir: string) { } } -export function copyFileIfChanged(src: string, dest: string) { +export function copyFileIfChanged( + src: string, + dest: string, + options?: CopyrightOptions +) { const destDir = path.dirname(dest); if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } if (!fs.existsSync(dest) || fs.readFileSync(src, 'utf8') !== fs.readFileSync(dest, 'utf8')) { - fs.copyFileSync(src, dest); + let content = fs.readFileSync(src, 'utf8'); + + if (options?.collectionName && options?.collectionVersion) { + content = addCopyrightComment(content, src, { + collectionName: options.collectionName, + collectionVersion: options.collectionVersion, + copyright: options.copyright + }); + } + + fs.writeFileSync(dest, content); } } @@ -86,7 +102,7 @@ export function writeFileIfChanged(content: string, dest: string) { export function cleanDir(dir: string) { if (fs.existsSync(dir)) { - fs.rmSync(dir, {recursive: true}) + fs.rmSync(dir, { recursive: true }) } fs.mkdirSync(dir) diff --git a/src/utilities/flags.ts b/src/utilities/flags.ts index bf5d7d13..f0595662 100644 --- a/src/utilities/flags.ts +++ b/src/utilities/flags.ts @@ -1,4 +1,4 @@ -import {Flags as OclifFlags} from '@oclif/core' +import { Flags as OclifFlags } from '@oclif/core' import { FlagInput } from '@oclif/core/interfaces'; import { ComponentConfig } from './types.js' @@ -26,7 +26,7 @@ export default class Flags { static readonly THEME = 'theme'; static readonly THEME_DIR = 'theme-dir'; static readonly WATCH = 'watch'; - + private flagValues: Record>; constructor(flags: Record>) { this.flagValues = flags diff --git a/src/utilities/package-json.ts b/src/utilities/package-json.ts index 130a4d27..d3f072dd 100644 --- a/src/utilities/package-json.ts +++ b/src/utilities/package-json.ts @@ -1,24 +1,56 @@ import fs from "node:fs"; import path from "node:path"; -export function getNameFromPackageJson(dir: string): string|undefined { - const pkgPath = path.join(dir, 'package.json'); +import { CopyrightConfig, PackageJSON } from "./types.js"; + +export function getNameFromPackageJson(dir: string): string | undefined { + const packagePath = path.join(dir, 'package.json'); let name; - if (fs.existsSync(pkgPath)) { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - name = pkg.name; + + if (fs.existsSync(packagePath)) { + const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + name = packageJson.name; } return name; } -export function getVersionFromPackageJson(dir: string): string|undefined { - const pkgPath = path.join(dir, 'package.json'); +export function getVersionFromPackageJson(dir: string): string | undefined { + const packagePath = path.join(dir, 'package.json'); let version; - if (fs.existsSync(pkgPath)) { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - version = pkg.version; + + if (fs.existsSync(packagePath)) { + const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + version = packageJson.version; } return version; -} \ No newline at end of file +} + +export function getCopyrightConfigFromPackageJson(dir: string): CopyrightConfig { + const packagePath = path.join(dir, 'package.json'); + + if (!fs.existsSync(packagePath)) { + return {}; + } + + try { + const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')) as PackageJSON; + const config: CopyrightConfig = {}; + + if (packageJson.author) { + config.author = typeof packageJson.author === 'string' + ? packageJson.author + : packageJson.author.name; + } + + if (packageJson.license) { + config.license = packageJson.license; + } + + return config; + } catch (error) { + console.error(`Failed to parse package.json for copyright config: ${error}`); + return {}; + } +} diff --git a/src/utilities/setup.ts b/src/utilities/setup.ts index c80b9aea..b2e95270 100644 --- a/src/utilities/setup.ts +++ b/src/utilities/setup.ts @@ -7,8 +7,8 @@ import { DeepObject, deepMerge } from './objects.js' import { LiquidNode } from './types.js' export async function copySetupComponentFiles( - collectionDir: string, - destination: string, + collectionDir: string, + destination: string, componentSelector: string ): Promise { const collectionNodes = await getCollectionNodes(collectionDir) @@ -37,7 +37,7 @@ export async function copySetupComponentFiles( // Write combined settings schema writeFileIfChanged( - JSON.stringify(settingsSchema), + JSON.stringify(settingsSchema), path.join(destination, 'config', 'settings_schema.json') ) @@ -49,7 +49,7 @@ export async function copySetupComponentFiles( } export async function processSettingsSchema( - setupFile: string, + setupFile: string, node: LiquidNode ): Promise { if (node?.name !== 'settings_schema.json') { diff --git a/src/utilities/types.ts b/src/utilities/types.ts index 8d5efd37..73a44399 100644 --- a/src/utilities/types.ts +++ b/src/utilities/types.ts @@ -17,7 +17,7 @@ export interface TomlConfig { export interface LiquidNode { assets: string[] - body: string + body: string file: string name: string setup: string[] @@ -27,6 +27,8 @@ export interface LiquidNode { } export interface PackageJSON { + author?: { name: string } | string; + license?: string; name: string; repository: string; version: string; @@ -44,4 +46,15 @@ export interface Manifest { [name: string]: string; }; } -} \ No newline at end of file +} + +export interface CopyrightConfig { + author?: string; + license?: string; +} + +export interface CopyrightOptions { + collectionName?: string; + collectionVersion?: string; + copyright?: CopyrightConfig; +} diff --git a/test/commands/theme/component/copy.test.ts b/test/commands/theme/component/copy.test.ts index 9d861a92..bf85d9cd 100644 --- a/test/commands/theme/component/copy.test.ts +++ b/test/commands/theme/component/copy.test.ts @@ -1,8 +1,8 @@ -import {runCommand} from '@oclif/test' -import {expect} from 'chai' +import { runCommand } from '@oclif/test' +import { expect } from 'chai' 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') @@ -13,34 +13,34 @@ const testThemePath = path.join(fixturesPath, 'test-theme') describe('theme component copy', () => { beforeEach(() => { - fs.cpSync(collectionPath, testCollectionPath, {recursive: true}) - fs.cpSync(themePath, testThemePath, {recursive: true}) + fs.cpSync(collectionPath, testCollectionPath, { 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(testThemePath, { force: true, recursive: true }) }) it('throws an error if the cwd is not a component collection', async () => { process.chdir(testThemePath) - const {error} = await runCommand(['theme', 'component', 'copy', testThemePath]) + const { error } = await runCommand(['theme', 'component', 'copy', testThemePath]) expect(error).to.be.instanceOf(Error) expect(error?.message).to.include('Warning: Current directory does not appear to be a component collection.') }) it('throws an error if the component.manifest.json file is not found in the theme directory', async () => { process.chdir(testCollectionPath) - fs.rmSync(path.join(testThemePath, 'component.manifest.json'), {force: true}) - const {error} = await runCommand(['theme', 'component', 'copy', testThemePath]) + 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.') }) it('throws an error if the version of the component collection does not match the version in the component.manifest.json file', async () => { process.chdir(testCollectionPath) - const {error} = await runCommand(['theme', 'component', 'copy', testThemePath]) + const { error } = await runCommand(['theme', 'component', 'copy', testThemePath]) expect(error).to.be.instanceOf(Error) expect(error?.message).to.include('Version mismatch:') }) @@ -59,4 +59,34 @@ describe('theme component copy', () => { expect(fs.existsSync(path.join(testThemePath, 'snippets', 'not-to-be-copied.liquid'))).to.be.false }) + + it('adds copyright information to copied files', async () => { + const packageJsonPath = path.join(testCollectionPath, 'package.json') + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) + + packageJson.author = 'Test Author' + packageJson.license = 'MIT' + + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)) + + const manifestPath = path.join(testThemePath, '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', testThemePath]) + + const cssContent = fs.readFileSync(path.join(testThemePath, 'assets', 'to-be-copied.css'), 'utf8') + const year = new Date().getFullYear() + const cssExpectedHeader = `/* @archetype-themes/test-collection v1.0.1 | Copyright © ${year} Test Author | MIT */` + expect(cssContent.startsWith(cssExpectedHeader)).to.be.true + + const jsContent = fs.readFileSync(path.join(testThemePath, 'assets', 'to-be-copied.js'), 'utf8') + const jsExpectedHeader = `// @archetype-themes/test-collection v1.0.1 | Copyright © ${year} Test Author | MIT` + expect(jsContent.startsWith(jsExpectedHeader)).to.be.true + + const liquidContent = fs.readFileSync(path.join(testThemePath, 'snippets', 'to-be-copied.liquid'), 'utf8') + const liquidExpectedHeader = `{% # @archetype-themes/test-collection v1.0.1 | Copyright © ${year} Test Author | MIT %}` + expect(liquidContent.startsWith(liquidExpectedHeader)).to.be.true + }) })