UNPKG

vite-plugin-shopify-assets

Version:
527 lines (520 loc) 21 kB
// src/options.ts import { resolve as resolve2, join as join2 } from "node:path"; import { constants } from "node:fs/promises"; import { normalizePath as normalizePath2 } from "vite"; import fg2 from "fast-glob"; // src/utils.ts import { basename, dirname, isAbsolute, join, relative, resolve, sep, parse } from "node:path"; import { existsSync } from "node:fs"; import { cp, unlink, readdir } from "node:fs/promises"; import pc from "picocolors"; import fg from "fast-glob"; import { normalizePath } from "vite"; var logMessage = (message, logger, level, timestamp = false) => { const color = level === "success" ? pc.green : level === "warn" ? pc.yellow : level === "error" ? pc.red : level === "info" ? pc.cyan : pc.dim; logger.info(pc.dim("[shopify-assets] ") + color(message), { timestamp }); }; var logMessageConsole = (message, level) => { const color = level === "success" ? pc.green : level === "warn" ? pc.yellow : level === "error" ? pc.red : level === "info" ? pc.cyan : pc.dim; console.log(pc.dim("[shopify-assets] ") + color(message)); }; var logWarn = (message, logger, timestamp = false) => logMessage(message, logger, "warn", timestamp); var logError = (message, logger, timestamp = false) => logMessage(message, logger, "error", timestamp); var logWarnConsole = (message) => logMessageConsole(message, "warn"); var logCopySuccess = (dest, src, logger, timestamp = false) => { logger.info( pc.dim(`[shopify-assets] ${relative(process.cwd(), dirname(dest))}${sep}`) + pc.green(basename(dest)) + pc.dim(` copied from ${relative(process.cwd(), dirname(src))}`), { timestamp } ); }; var logCopyError = (dest, src, logger, timestamp = false) => { logger.info( pc.dim(`[shopify-assets] could not copy ${relative(process.cwd(), dirname(dest))}${sep}`) + pc.red(basename(dest)) + pc.dim(` from ${relative(process.cwd(), dirname(src))}`), { timestamp } ); }; var logEvent = (type, path, logger, timestamp = false) => { const color = type === "delete" ? pc.red : type === "create" ? pc.green : type === "update" ? pc.cyan : pc.dim; logger.info( pc.dim(`[shopify-assets] ${dirname(path)}${path.includes(sep) ? sep : ""}`) + color(basename(path)) + pc.dim(` ${type}d`), { timestamp } ); }; var logEventIgnored = (type, path, logger, timestamp = false) => { logger.info( pc.dim(`[shopify-assets] ${dirname(path)}${path.includes(sep) ? sep : ""}`) + pc.yellow(basename(path)) + pc.dim(` ${type} ignored`), { timestamp } ); }; var isChildDir = (base, target) => { const relation = relative(base, target); return relation !== "" && !relation.startsWith("..") && !isAbsolute(relation); }; var renameFile = async (file, src, rename) => { if (typeof rename === "string") { return rename; } const { name, ext } = parse(file); return rename(name, ext.replace(".", ""), src); }; var copyAsset = async (target, fileChanged, event, logger, silent = true) => { const { base: file } = parse(fileChanged); const destPath = target.rename ? resolve(target.dest, await renameFile(file, file, target.rename)) : resolve(target.dest, file); const relativePath = relative(process.cwd(), destPath); cp(fileChanged, destPath, { dereference: target.dereference, errorOnExist: target.errorOnExist, force: target.force, mode: target.mode, preserveTimestamps: target.preserveTimestamps }).then(() => logEvent(event, relativePath, logger, true)).catch((error) => { logError(`could not create ${relativePath}`, logger, true); if (!silent && error instanceof Error) logger.error(error.message); }); }; var deleteAsset = async (target, fileChanged, event, logger, silent = true) => { const { base: file } = parse(fileChanged); const destPath = target.rename ? resolve(target.dest, await renameFile(file, file, target.rename)) : resolve(target.dest, file); const relativePath = relative(process.cwd(), destPath); unlink(destPath).then(() => logEvent(event, relativePath, logger, true)).catch((error) => { logError(`Could not delete ${relativePath}`, logger, true); if (!silent && error instanceof Error) logger.error(error.message); }); }; var copyAllAssets = async (target, logger, options = { silent: true, timestamp: false }) => { const assetFiles = await fg(normalizePath(target.src), { ignore: target.ignore }); if (!assetFiles.length) return; const { silent, timestamp } = options; for (const src of assetFiles) { const { base: file } = parse(src); const dest = target.rename ? normalizePath(resolve(target.dest, await renameFile(file, src, target.rename))) : normalizePath(resolve(target.dest, file)); const fileExists = existsSync(dest); cp(src, dest, { dereference: target.dereference, errorOnExist: target.errorOnExist, force: target.force, mode: target.mode, preserveTimestamps: target.preserveTimestamps }).then(() => { if (!fileExists) logCopySuccess(dest, src, logger, timestamp); }).catch((error) => { logCopyError(dest, src, logger, timestamp); if (!silent && error instanceof Error) logger.error(error.message); }); } }; var copyAllAssetMap = async (assetMap, logger, options = { silent: true, timestamp: false }) => { if (!assetMap?.size) return; const { silent, timestamp } = options; for (const [src, target] of assetMap.entries()) { const fileExists = existsSync(target.dest); await cp(src, target.dest, { dereference: target.dereference, errorOnExist: target.errorOnExist, force: target.force, mode: target.mode, preserveTimestamps: target.preserveTimestamps }).then(() => { if (!fileExists) logCopySuccess(target.dest, src, logger, timestamp); }).catch((error) => { logCopyError(target.dest, src, logger, timestamp); if (!silent && error instanceof Error) logger.error(error.message); }); } }; var getBundleFiles = (bundle) => { if (!bundle || !Object.keys(bundle).length) { return []; } return Object.entries(bundle).reduce((acc, [fileName, chunk]) => { if (fileName.startsWith(".vite/")) { return [...acc, ".vite"]; } if (chunk.type === "asset") { return [...acc, fileName]; } if (chunk.type === "chunk") { const importedFiles = []; if (chunk.viteMetadata?.importedCss?.size) { chunk.viteMetadata.importedCss.forEach((cssFile) => { importedFiles.push(cssFile); }); } if (chunk.viteMetadata?.importedAssets?.size) { chunk.viteMetadata.importedAssets.forEach((assetFile) => { importedFiles.push(assetFile); }); } return [...acc, fileName, ...importedFiles]; } return acc; }, []); }; // src/constants.ts var VITE_PUBLIC_DIRNAME = "public"; var THEME_ASSETS_DIRNAME = "assets"; // src/options.ts var resolveOptions = (options) => { const publicDir = options?.publicDir ? resolve2(options.publicDir) : resolve2(process.cwd(), VITE_PUBLIC_DIRNAME); const themeRoot = options?.themeRoot ? resolve2(options.themeRoot) : resolve2(process.cwd()); const themeAssetsDir = join2(themeRoot, THEME_ASSETS_DIRNAME); const targets = options?.targets?.length ? options.targets.map((target) => { if (typeof target === "string") { return { src: normalizePath2(join2(publicDir, target)), dest: normalizePath2(resolve2(themeRoot, themeAssetsDir)), cleanMatch: void 0, ignore: [], rename: void 0, dereference: true, errorOnExist: false, force: true, mode: 0, preserveTimestamps: true }; } if (target.dest && fg2.isDynamicPattern(target.dest)) { throw new Error("[shopify-assets] Dynamic patterns are not supported in target.dest"); } return { src: normalizePath2(join2(publicDir, target.src)), dest: normalizePath2(target.dest ? join2(themeRoot, target.dest) : themeAssetsDir), cleanMatch: resolveCleanMatch(themeRoot, target, options.silent), ignore: Array.isArray(target?.ignore) ? target.ignore.map((_ignore) => normalizePath2(join2(publicDir, _ignore))) : typeof target.ignore === "string" ? [normalizePath2(join2(publicDir, target.ignore))] : [], rename: target.rename, dereference: target.dereference ?? true, errorOnExist: target.force === "error", force: target.force === "error" ? false : true, mode: target.force === "error" ? constants.COPYFILE_EXCL : 0, preserveTimestamps: target.preserveTimestamps ?? true }; }) : [ { src: normalizePath2(join2(publicDir, "*")), dest: normalizePath2(resolve2(themeRoot, themeAssetsDir)), cleanMatch: void 0, ignore: [], rename: void 0, dereference: true, errorOnExist: false, force: true, mode: 0, preserveTimestamps: true } ]; return { publicDir, themeAssetsDir, themeRoot, targets, onServe: options?.onServe ?? true, onBuild: options?.onBuild ?? true, onWatch: options?.onWatch ?? true, silent: options?.silent ?? true }; }; function resolveCleanMatch(themeRoot, target, silent = true) { if (!target.cleanMatch) { return void 0; } if (!target.dest || target.dest === THEME_ASSETS_DIRNAME) { if (!silent) { logWarnConsole( "WARNING: target.cleanMatch will have no effect when target.dest is not set or is equal to the default value." ); } return void 0; } if (target.cleanMatch && (target.cleanMatch === "**/*" || target.cleanMatch === "**/*.*" || target.cleanMatch === "**" || target.cleanMatch === "*" || target.cleanMatch === "*.*")) { if (!silent) { logWarnConsole( "WARNING: target.cleanMatch pattern is too generic and will be disabled to prevent accidentally deleting files." ); } return void 0; } return normalizePath2(join2(themeRoot, target.dest, target.cleanMatch)); } // src/serve.ts import { parse as parse2, relative as relative2, resolve as resolve3 } from "node:path"; import { existsSync as existsSync2, mkdirSync } from "node:fs"; import { unlink as unlink2 } from "node:fs/promises"; import picomatch from "picomatch"; import fg3 from "fast-glob"; import { normalizePath as normalizePath3 } from "vite"; var servePlugin = ({ publicDir, themeRoot, themeAssetsDir, targets, silent, onServe }) => { let logger; const currentDir = resolve3(); return { name: "vite-plugin-shopify-assets:serve", apply: "serve", config: () => ({ publicDir }), configResolved(_config) { logger = _config.logger; if (targets.length > 0 && !existsSync2(publicDir)) { const relativePublicDir = relative2(currentDir, publicDir); logWarn( `Your publicDir does not exist, creating it at ${relativePublicDir}/ - Use this folder to store the source static assets for your Shopify theme`, logger ); mkdirSync(publicDir); } if (!existsSync2(themeAssetsDir)) { const relativeThemeAssetsDir = relative2(currentDir, themeAssetsDir); logWarn( `Your Shopify theme assets folder does not exist - creating it at ${relativeThemeAssetsDir}/ - Your static assets will be copied to this folder`, logger ); mkdirSync(themeAssetsDir); } }, async buildStart() { if (!onServe) { if (!silent) logWarn("Skipping serve", logger); return; } for (const target of targets) { if (!target.cleanMatch) continue; const assetFiles = await fg3(normalizePath3(target.src), { ignore: target.ignore }); if (!assetFiles.length) continue; const filesToKeep = []; for (const src of assetFiles) { const { base: file } = parse2(src); const resolvedDest = target.rename ? normalizePath3(resolve3(target.dest, await renameFile(file, src, target.rename))) : normalizePath3(resolve3(target.dest, file)); filesToKeep.push(resolvedDest); } const filesToDelete = await fg3(target.cleanMatch, { ignore: filesToKeep }); if (!filesToDelete.length) continue; await Promise.all( filesToDelete.map( async (file) => existsSync2(file) ? unlink2(file).then(() => Promise.resolve(file)) : Promise.resolve(file) ) ).then((results) => { if (!results.length) return; for (const fileDeleted of results) { const relativePath = relative2(themeRoot, fileDeleted); logEvent("delete", relativePath, logger, true); } }).catch((error) => { if (silent) return; const message = error instanceof Error ? error.message : "An unknown error occurred while deleting files"; logger.error(message); }); } for (const target of targets) { await copyAllAssets(target, logger, { silent, timestamp: true }); } }, async watchChange(fileChanged, { event }) { if (!onServe) return; const target = targets.find((_target) => picomatch(_target.src)(fileChanged)); if (!target) return; if (target.ignore.some((glob) => picomatch(glob)(fileChanged))) { const relativeIgnored = relative2(themeAssetsDir, fileChanged); logEventIgnored(event, relativeIgnored, logger, true); return; } switch (event) { case "create": case "update": return copyAsset(target, fileChanged, event, logger, silent).catch((error) => { if (silent) return; const message = error instanceof Error ? error.message : "An unknown error occurred while copying files"; logger.error(message); }); case "delete": return deleteAsset(target, fileChanged, event, logger, silent).catch((error) => { if (silent) return; const message = error instanceof Error ? error.message : "An unknown error occurred while deleting files"; logger.error(message); }); } } }; }; // src/build.ts import { basename as basename2, join as join3, dirname as dirname2, relative as relative3, resolve as resolve4 } from "node:path"; import { unlink as unlink3 } from "node:fs/promises"; import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync } from "node:fs"; import fg4 from "fast-glob"; import { normalizePath as normalizePath4 } from "vite"; var buildPlugin = ({ publicDir, themeRoot, themeAssetsDir, targets, onBuild, onWatch, silent }) => { let logger; let clean; const currentDir = resolve4(); const assetMap = /* @__PURE__ */ new Map(); const assetDirSet = /* @__PURE__ */ new Set(); const assetDestSet = /* @__PURE__ */ new Set(); const assetFilesSet = /* @__PURE__ */ new Set(); return { name: "vite-plugin-shopify-assets:build", apply: "build", config: (_config) => { if (_config.build?.copyPublicDir === true) { logWarnConsole("Vite config.build.copyPublicDir is enabled, but it will be ignored."); } if (typeof _config?.publicDir !== "undefined") { const relativePublicDir = relative3(currentDir, publicDir); if (_config?.publicDir !== false) { logWarnConsole( `Your vite config.publicDir option is set to "${_config.publicDir}", but it will be ignored - Please set this in the plugin options instead. Using: ${relativePublicDir}. ` ); } } const isValidThemeAssetsDir = isChildDir(themeRoot, themeAssetsDir); if (_config?.build?.emptyOutDir !== false && !isValidThemeAssetsDir) { logWarnConsole(`Your theme assets directory is not located inside themeRoot. Clean will be disabled.`); } clean = _config?.build?.emptyOutDir !== false && isValidThemeAssetsDir; return { publicDir, build: { // We cannot let vite copy files because directories won't be flattened // and that's incompatible with Shopify theme structures. copyPublicDir: false, // We cannot let vite empty the outDir because it will delete our assets // We need to force disable it (we have a special variable for that: `clean`). emptyOutDir: false } }; }, configResolved(_config) { logger = _config.logger; if (targets.length > 0 && !existsSync3(publicDir)) { const relativePublicDir = relative3(currentDir, publicDir); logWarn( `Your publicDir does not exist, creating it at ${relativePublicDir}/ - Use this folder to store the source static assets for your Shopify theme`, logger ); mkdirSync2(publicDir); } if (!existsSync3(themeAssetsDir)) { const relativeThemeAssetsDir = relative3(currentDir, themeAssetsDir); logWarn( `Your Shopify theme assets folder does not exist - creating it at ${relativeThemeAssetsDir}/ - Your static assets will be copied to this folder`, logger ); mkdirSync2(themeAssetsDir); } }, async buildStart() { assetMap.clear(); assetDestSet.clear(); for (const target of targets) { const assetFiles = await fg4(normalizePath4(target.src), { ignore: target.ignore }); for (const file of assetFiles) { const fileName = target.rename ? await renameFile(basename2(file), file, target.rename) : basename2(file); const resolvedDest = normalizePath4(resolve4(target.dest, fileName)); if (onWatch && this.meta.watchMode) { assetDirSet.add(normalizePath4(resolve4(themeAssetsDir, dirname2(file)))); } if (assetDestSet.has(resolvedDest)) { if (!silent) { const relativeDupeSrc = normalizePath4(relative3(publicDir, file)); logWarn(`Duplicate asset found. Ignoring ${relativeDupeSrc}`, logger, true); } continue; } assetDestSet.add(resolvedDest); assetMap.set(file, { ...target, dest: resolvedDest }); assetFilesSet.add(basename2(file)); } } if (onWatch && this.meta.watchMode) { for (const dir of assetDirSet.values()) { this.addWatchFile(dir); } } }, async writeBundle(_, bundle) { if (!clean) return; const themeAssetFiles = readdirSync(themeAssetsDir); const newBundleFiles = getBundleFiles(bundle); const oldFiles = themeAssetFiles.filter((file) => !newBundleFiles.includes(file) && !assetFilesSet.has(file)).map((file) => normalizePath4(join3(themeAssetsDir, file))); const filesToDelete = new Set(oldFiles.length ? oldFiles : []); for (const target of targets) { if (!target.cleanMatch) continue; const matchFiles = await fg4(target.cleanMatch); if (!matchFiles.length) continue; const matchToDelete = matchFiles.filter((file) => !assetDestSet.has(file)); if (!matchToDelete.length) continue; matchToDelete.forEach((file) => filesToDelete.add(file)); } if (!filesToDelete.size) return; await Promise.all( Array.from(filesToDelete).map(async (file) => { return existsSync3(file) ? unlink3(file).then(() => Promise.resolve(file)) : Promise.resolve(file); }) ).catch((error) => { if (silent) return; const message = error instanceof Error ? error.message : "An unknown error occurred while deleting files"; logger.error(message); }); }, async closeBundle() { if (onBuild || onWatch && this.meta.watchMode) { await copyAllAssetMap(assetMap, logger, { silent, timestamp: false }); } }, // eslint-disable-next-line @typescript-eslint/require-await async watchChange(fileChanged, { event }) { if (!assetDirSet.has(dirname2(fileChanged))) { return; } const asset = assetMap.get(fileChanged); if (!asset) { return; } if (event === "delete") { const normalizedDest = normalizePath4(asset.dest); if (existsSync3(normalizedDest)) { unlink3(normalizedDest).then(() => { const relativeDeleted = relative3(themeRoot, asset.dest); logEvent(event, relativeDeleted, logger); }).catch((error) => { if (silent) return; const message = error instanceof Error ? error.message : "An unknown error occurred while deleting files"; logger.error(message); }); } assetMap.delete(fileChanged); assetFilesSet.delete(basename2(fileChanged)); } }, async closeWatcher() { await copyAllAssetMap(assetMap, logger, { silent, timestamp: false }); } }; }; // src/index.ts var shopifyAssets = (options) => { const resolvedOptions = resolveOptions(options); return [buildPlugin(resolvedOptions), servePlugin(resolvedOptions)]; }; var src_default = shopifyAssets; export { src_default as default };