vite-plugin-shopify-theme-islands
Version:
Vite plugin for island architecture in Shopify themes
236 lines (235 loc) • 9.07 kB
JavaScript
// src/index.ts
import { readFileSync, readdirSync } from "node:fs";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
var VIRTUAL_ID = "vite-plugin-shopify-theme-islands/revive";
var RESOLVED_ID = "\x00" + VIRTUAL_ID;
var ISLAND_ID = "vite-plugin-shopify-theme-islands/island";
var runtimePath = fileURLToPath(new URL("./runtime.js", import.meta.url));
var islandPath = fileURLToPath(new URL("./island.js", import.meta.url));
var ISLAND_IMPORT_RE = /from\s+['"]vite-plugin-shopify-theme-islands\/island['"]/;
var TS_JS_RE = /\.(ts|js)$/;
var SKIP_DIRS = new Set(["node_modules", "dist", "build", "public", "assets", ".cache"]);
var PREFIX = "[vite-plugin-shopify-theme-islands]";
function validateOptions(options, directives) {
const customDefs = options.directives?.custom ?? [];
if (Array.isArray(options.directories) && options.directories.length === 0) {
throw new Error(`${PREFIX} "directories" must not be empty`);
}
const threshold = options.directives?.visible?.threshold;
if (threshold !== undefined && (threshold < 0 || threshold > 1)) {
throw new Error(`${PREFIX} "directives.visible.threshold" must be between 0 and 1, got ${threshold}`);
}
if (options.retry !== undefined) {
const { retries, delay } = options.retry;
if (retries !== undefined && retries < 0) {
throw new Error(`${PREFIX} "retry.retries" must be >= 0, got ${retries}`);
}
if (delay !== undefined && delay < 0) {
throw new Error(`${PREFIX} "retry.delay" must be >= 0, got ${delay}`);
}
}
const builtinAttributes = new Set([
directives.visible.attribute,
directives.idle.attribute,
directives.media.attribute,
directives.defer.attribute,
directives.interaction.attribute
]);
const seen = new Set;
for (const def of customDefs) {
if (seen.has(def.name)) {
throw new Error(`${PREFIX} Duplicate custom directive name: "${def.name}"`);
}
if (builtinAttributes.has(def.name)) {
throw new Error(`${PREFIX} Custom directive "${def.name}" conflicts with a built-in directive`);
}
seen.add(def.name);
}
}
var defaults = {
directories: ["/frontend/js/islands/"],
directives: {
visible: { attribute: "client:visible", rootMargin: "200px", threshold: 0 },
idle: { attribute: "client:idle", timeout: 500 },
media: { attribute: "client:media" },
defer: { attribute: "client:defer", delay: 3000 },
interaction: {
attribute: "client:interaction",
events: ["mouseenter", "touchstart", "focusin"]
}
}
};
function normalizeDir(dir) {
return dir.endsWith("/") ? dir : dir + "/";
}
function resolveAliases(dirs, config) {
const aliases = [...config.resolve.alias].sort((a, b) => (typeof b.find === "string" ? b.find.length : 0) - (typeof a.find === "string" ? a.find.length : 0));
return dirs.map((dir) => {
for (const { find, replacement } of aliases) {
if (typeof find === "string" && dir.startsWith(find))
return dir.replace(find, replacement);
if (find instanceof RegExp && find.test(dir))
return dir.replace(find, replacement);
}
return dir;
});
}
function walkDir(dir, visitor) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
continue;
const full = join(dir, entry.name);
if (entry.isDirectory())
walkDir(full, visitor);
else if (TS_JS_RE.test(entry.name))
visitor(entry.name, full);
}
}
function collectTagNames(dir, names) {
walkDir(dir, (name) => names.push(name.replace(TS_JS_RE, "")));
}
function scanForIslandFiles(dir, found) {
walkDir(dir, (_, full) => {
try {
if (ISLAND_IMPORT_RE.test(readFileSync(full, "utf-8")))
found.add(full);
} catch {}
});
}
function shopifyThemeIslands(options = {}) {
const rawDirs = (Array.isArray(options.directories) ? options.directories : [options.directories ?? defaults.directories[0]]).map(normalizeDir);
const directives = {
visible: { ...defaults.directives.visible, ...options.directives?.visible },
idle: { ...defaults.directives.idle, ...options.directives?.idle },
media: { ...defaults.directives.media, ...options.directives?.media },
defer: { ...defaults.directives.defer, ...options.directives?.defer },
interaction: { ...defaults.directives.interaction, ...options.directives?.interaction }
};
const clientDirectiveDefinitions = options.directives?.custom ?? [];
validateOptions(options, directives);
const debug = options.debug ?? false;
const log = debug ? (...args) => console.log("[islands]", ...args) : () => {};
let resolvedDirs = rawDirs;
let root = process.cwd();
let absDirs = rawDirs;
const islandFiles = new Set;
let scanned = false;
const inDirectory = (file) => absDirs.some((dir) => file.startsWith(dir));
return {
name: "vite-plugin-shopify-theme-islands",
enforce: "pre",
configResolved(config) {
root = config.root;
resolvedDirs = resolveAliases(rawDirs, config);
absDirs = resolvedDirs.map((d) => d.startsWith(root) ? d : join(root, d.replace(/^\//, "")));
},
buildStart() {
if (scanned)
return;
scanned = true;
const t0 = performance.now();
scanForIslandFiles(root, islandFiles);
const scanMs = (performance.now() - t0).toFixed(1);
for (const f of islandFiles)
if (inDirectory(f))
islandFiles.delete(f);
if (debug) {
log(`Scanned in ${scanMs}ms`);
log("Scanning directories:", resolvedDirs.map((d) => d + "**/*.{ts,js}").join(", "));
const dirNames = [];
for (const dir of absDirs)
collectTagNames(dir, dirNames);
if (dirNames.length)
log(`Found ${dirNames.length} directory island(s): [${dirNames.join(", ")}]`);
if (islandFiles.size) {
log(`Found ${islandFiles.size} island file(s) via mixin import:`);
for (const f of islandFiles)
log(" ", relative(root, f));
}
log("Directives:", directives);
}
},
transform(code, id) {
if (!TS_JS_RE.test(id))
return;
if (code.includes("shopify-theme-islands/island") && ISLAND_IMPORT_RE.test(code) && !inDirectory(id)) {
islandFiles.add(id);
log("Detected island:", relative(root, id));
} else {
if (islandFiles.delete(id))
log("Removed island:", relative(root, id));
}
},
watchChange(id, { event }) {
if (!TS_JS_RE.test(id))
return;
if (event === "delete") {
if (islandFiles.delete(id))
log("Removed island (deleted):", relative(root, id));
} else {
try {
const content = readFileSync(id, "utf-8");
if (ISLAND_IMPORT_RE.test(content) && !inDirectory(id)) {
islandFiles.add(id);
log("Detected island (watchChange):", relative(root, id));
} else {
if (islandFiles.delete(id))
log("Removed island (watchChange):", relative(root, id));
}
} catch {}
}
},
resolveId(id) {
if (id === VIRTUAL_ID)
return RESOLVED_ID;
if (id === ISLAND_ID)
return islandPath;
},
async load(id) {
if (id !== RESOLVED_ID)
return;
const globs = resolvedDirs.map((dir) => `...import.meta.glob(${JSON.stringify(dir + "**/*.{ts,js}")})`);
const islandPaths = islandFiles.size ? [...islandFiles].map((file) => "/" + relative(root, file).replace(/\\/g, "/")) : null;
const islandsEntries = [`{ ${globs.join(", ")} }`];
if (islandPaths)
islandsEntries.push(`import.meta.glob(${JSON.stringify(islandPaths)})`);
const directiveImports = [];
const mapEntries = [];
for (const [i, def] of clientDirectiveDefinitions.entries()) {
const resolved = await this.resolve(def.entrypoint);
if (!resolved) {
throw new Error(`[vite-plugin-shopify-theme-islands] Cannot resolve custom directive entrypoint: "${def.entrypoint}"`);
}
directiveImports.push(`import _directive${i} from ${JSON.stringify(resolved.id)};`);
mapEntries.push(` [${JSON.stringify(def.name)}, _directive${i}]`);
}
const lines = [
...directiveImports,
`import { revive as _islands } from ${JSON.stringify(runtimePath)};`,
`const islands = Object.assign({}, ${islandsEntries.join(", ")});`,
`const options = ${JSON.stringify({ directives, debug, retry: options.retry })};`
];
if (mapEntries.length) {
lines.push(`const customDirectives = new Map([
${mapEntries.join(`,
`)}
]);`);
lines.push(`export const { disconnect } = _islands(islands, options, customDirectives);`);
} else {
lines.push(`export const { disconnect } = _islands(islands, options);`);
}
return lines.join(`
`);
}
};
}
export {
shopifyThemeIslands as default
};