vite-plugin-shopify-assets
Version:
Vite plugin to handle static assets in Vite Shopify themes
527 lines (520 loc) • 21 kB
JavaScript
// 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
};