vite-plugin-shopify-theme-islands
Version:
Vite plugin for island architecture in Shopify themes
458 lines (450 loc) • 16.2 kB
JavaScript
// src/index.ts
import { relative as relative2 } from "node:path";
// src/discovery.ts
import { readFileSync, readdirSync } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
var TS_JS_RE = /\.(ts|js)$/;
var SKIP_DIRS = new Set(["node_modules", "dist", "build", "public", "assets", ".cache"]);
var ISLAND_IMPORT_RE = /from\s+['"]vite-plugin-shopify-theme-islands\/island['"]/;
function inDirectory(file, absDirs) {
const resolvedFile = resolve(file);
return absDirs.some((dir) => {
const rel = relative(resolve(dir), resolvedFile);
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
});
}
function getIslandPathsForLoad(islandFiles, root) {
return [...islandFiles].map((file) => "/" + relative(root, file).replace(/\\/g, "/"));
}
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 resolveAliases(dirs, aliasesInput) {
const aliases = [...aliasesInput].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 toAbsoluteDirs(root, resolvedDirs) {
return resolvedDirs.map((dir) => dir.startsWith(root) ? dir : join(root, dir.replace(/^\//, "")));
}
function discoverIslandFiles(root, absDirs) {
const found = new Set;
walkDir(root, (_, full) => {
try {
if (ISLAND_IMPORT_RE.test(readFileSync(full, "utf-8")))
found.add(full);
} catch {}
});
for (const f of [...found])
if (inDirectory(f, absDirs))
found.delete(f);
return found;
}
function collectTagNames(dir) {
const names = [];
walkDir(dir, (name) => names.push(name.replace(TS_JS_RE, "")));
return names;
}
function createIslandInventory(rawDirectories) {
let root = process.cwd();
let resolvedDirs = [...rawDirectories];
let absDirs = [...rawDirectories];
const islandFiles = new Set;
let scanned = false;
const buildSnapshot = () => ({
resolvedDirectories: [...resolvedDirs],
islandFiles: [...islandFiles],
directoryTagNames: absDirs.flatMap((dir) => collectTagNames(dir))
});
const updateIslandFile = (id, code) => {
if (!TS_JS_RE.test(id))
return null;
if (code.includes("shopify-theme-islands/island") && ISLAND_IMPORT_RE.test(code) && !inDirectory(id, absDirs)) {
const sizeBefore = islandFiles.size;
islandFiles.add(id);
return islandFiles.size !== sizeBefore ? { type: "detected", file: id } : null;
}
return islandFiles.delete(id) ? { type: "removed", file: id } : null;
};
return {
configure(config) {
root = config.root;
resolvedDirs = resolveAliases(rawDirectories, config.aliases);
absDirs = toAbsoluteDirs(root, resolvedDirs);
},
scan() {
if (scanned)
return null;
scanned = true;
islandFiles.clear();
discoverIslandFiles(root, absDirs).forEach((file) => islandFiles.add(file));
return buildSnapshot();
},
applyTransform(id, code) {
return updateIslandFile(id, code);
},
applyWatchChange(id, event) {
if (!TS_JS_RE.test(id))
return null;
if (event === "delete") {
return islandFiles.delete(id) ? { type: "removed", file: id } : null;
}
try {
return updateIslandFile(id, readFileSync(id, "utf-8"));
} catch {
return null;
}
},
getBootstrapState() {
return {
root,
directories: [...resolvedDirs],
islandFiles: new Set(islandFiles)
};
},
getRoot() {
return root;
}
};
}
// src/interaction-events.ts
var INTERACTION_EVENT_NAMES = ["mouseenter", "touchstart", "focusin"];
var DEFAULT_INTERACTION_EVENTS = [...INTERACTION_EVENT_NAMES];
var INTERACTION_EVENT_NAMES_LABEL = INTERACTION_EVENT_NAMES.join(", ");
var INTERACTION_EVENT_NAME_SET = new Set(INTERACTION_EVENT_NAMES);
var PREFIX = "[vite-plugin-shopify-theme-islands]";
function isInteractionEventName(value) {
return INTERACTION_EVENT_NAME_SET.has(value);
}
function validateInteractionEvents(events) {
if (events === undefined)
return;
if (events.length === 0) {
throw new Error(`${PREFIX} "directives.interaction.events" must not be empty`);
}
const { invalid } = partitionInteractionEventTokens(events);
const invalidEvent = invalid[0];
if (invalidEvent) {
throw new Error(`${PREFIX} "directives.interaction.events" contains unsupported event "${invalidEvent}"`);
}
}
function partitionInteractionEventTokens(tokens) {
const valid = [];
const invalid = [];
for (const token of tokens) {
if (isInteractionEventName(token))
valid.push(token);
else
invalid.push(token);
}
return { valid, invalid };
}
// src/contract.ts
var DEFAULT_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: [...DEFAULT_INTERACTION_EVENTS]
}
};
var DEFAULT_RETRY = { retries: 0, delay: 1000 };
function normalizeReviveOptions(options) {
const d = DEFAULT_DIRECTIVES;
const r = DEFAULT_RETRY;
const dir = options?.directives;
validateInteractionEvents(dir?.interaction?.events);
return {
directives: {
visible: { ...d.visible, ...dir?.visible },
idle: { ...d.idle, ...dir?.idle },
media: { ...d.media, ...dir?.media },
defer: { ...d.defer, ...dir?.defer },
interaction: { ...d.interaction, ...dir?.interaction }
},
debug: options?.debug ?? false,
retry: { ...r, ...options?.retry },
directiveTimeout: options?.directiveTimeout ?? 0
};
}
var basename = (key) => key.split("/").pop() ?? key;
function defaultKeyToTag(key) {
const filename = basename(key);
const tag = filename.replace(/\.(ts|js)$/, "");
const skip = !tag.includes("-");
if (skip && tag)
console.warn(`[islands] Skipping "${filename}" — filename must contain a hyphen to match a valid custom element tag (e.g. rename to "${tag}-island.ts")`);
return { tag, skip };
}
function buildIslandMap(payload) {
const map = new Map;
for (const [key, loader] of Object.entries(payload.islands)) {
const { tag, skip } = defaultKeyToTag(key);
if (skip)
continue;
if (!map.has(tag))
map.set(tag, loader);
}
return map;
}
// src/config-policy.ts
var PREFIX2 = "[vite-plugin-shopify-theme-islands]";
function mergeDirectives(directives) {
return {
visible: { ...DEFAULT_DIRECTIVES.visible, ...directives?.visible },
idle: { ...DEFAULT_DIRECTIVES.idle, ...directives?.idle },
media: { ...DEFAULT_DIRECTIVES.media, ...directives?.media },
defer: { ...DEFAULT_DIRECTIVES.defer, ...directives?.defer },
interaction: { ...DEFAULT_DIRECTIVES.interaction, ...directives?.interaction }
};
}
function validateOptions(options, directives) {
const customDefs = options.directives?.custom ?? [];
if (Array.isArray(options.directories) && options.directories.length === 0) {
throw new Error(`${PREFIX2} "directories" must not be empty`);
}
const threshold = options.directives?.visible?.threshold;
if (threshold !== undefined && (threshold < 0 || threshold > 1)) {
throw new Error(`${PREFIX2} "directives.visible.threshold" must be between 0 and 1, got ${threshold}`);
}
const interactionEvents = options.directives?.interaction?.events;
validateInteractionEvents(interactionEvents);
if (options.retry !== undefined) {
const { retries, delay } = options.retry;
if (retries !== undefined && retries < 0) {
throw new Error(`${PREFIX2} "retry.retries" must be >= 0, got ${retries}`);
}
if (delay !== undefined && delay < 0) {
throw new Error(`${PREFIX2} "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(`${PREFIX2} Duplicate custom directive name: "${def.name}"`);
}
if (builtinAttributes.has(def.name)) {
throw new Error(`${PREFIX2} Custom directive "${def.name}" conflicts with a built-in directive`);
}
seen.add(def.name);
}
}
function resolveThemeIslandsPolicy(options = {}) {
const directives = mergeDirectives(options.directives);
validateOptions(options, directives);
const customDirectives = options.directives?.custom ?? [];
const debug = options.debug ?? false;
const runtime = {
directives,
debug,
...options.retry !== undefined ? { retry: options.retry } : {},
...options.directiveTimeout !== undefined ? { directiveTimeout: options.directiveTimeout } : {}
};
return {
plugin: {
directives,
customDirectives,
debug
},
runtime
};
}
// src/revive-module.ts
function buildReviveModuleSource(params) {
const { runtimePath, directoryGlobs, islandPaths, customDirectives, reviveOptions } = params;
const directiveImportLines = customDirectives?.map(({ entrypoint }, index) => `import _directive${index} from ${JSON.stringify(entrypoint)};`) ?? [];
const globEntries = [
`{ ${directoryGlobs.map((glob) => `...import.meta.glob(${JSON.stringify(glob)})`).join(", ")} }`
];
if (islandPaths?.length)
globEntries.push(`import.meta.glob(${JSON.stringify(islandPaths)})`);
const lines = [
...directiveImportLines,
`import { revive as _islands } from ${JSON.stringify(runtimePath)};`,
`const islands = Object.assign({}, ${globEntries.join(", ")});`,
`const options = ${JSON.stringify(reviveOptions)};`
];
if (customDirectives?.length) {
const customDirectivesMapLines = customDirectives.map(({ name }, index) => ` [${JSON.stringify(name)}, _directive${index}]`);
lines.push(`const customDirectives = new Map([
${customDirectivesMapLines.join(`,
`)}
]);`);
lines.push(`const payload = { islands, options, customDirectives };`);
} else {
lines.push(`const payload = { islands, options };`);
}
lines.push(`export const { disconnect } = _islands(payload);`);
return lines.join(`
`);
}
// src/revive-bootstrap.ts
function createReviveBootstrapCompiler(ports, runtimePath) {
return {
async plan(input) {
const islandPaths = input.islandFiles.size > 0 ? ports.toLoadPaths(input.islandFiles, input.root) : null;
const customDirectives = input.customDirectives?.length ? await Promise.all(input.customDirectives.map(async ({ name, entrypoint }) => ({
name,
entrypoint: await ports.resolveEntrypoint(entrypoint)
}))) : null;
const directoryGlobs = input.directories.map((dir) => dir + "**/*.{ts,js}");
return {
runtimePath,
directoryGlobs,
islandPaths,
customDirectives,
reviveOptions: input.reviveOptions
};
},
emit(plan) {
return buildReviveModuleSource({
runtimePath: plan.runtimePath,
directoryGlobs: plan.directoryGlobs,
islandPaths: plan.islandPaths,
customDirectives: plan.customDirectives?.length ? plan.customDirectives : undefined,
reviveOptions: plan.reviveOptions
});
}
};
}
// src/index.ts
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 defaultDirectories = ["/frontend/js/islands/"];
function normalizeDir(dir) {
return dir.endsWith("/") ? dir : dir + "/";
}
function shopifyThemeIslands(options = {}) {
const rawDirs = (Array.isArray(options.directories) ? options.directories : [options.directories ?? defaultDirectories[0]]).map(normalizeDir);
const policy = resolveThemeIslandsPolicy(options);
const { directives, customDirectives: clientDirectiveDefinitions, debug } = policy.plugin;
const { runtime: reviveOptions } = policy;
const log = debug ? (...args) => console.log("[islands]", ...args) : () => {};
const inventory = createIslandInventory(rawDirs);
let devServer = null;
const invalidateReviveModule = () => {
if (!devServer)
return;
const mod = devServer.moduleGraph.getModuleById(RESOLVED_ID);
if (!mod)
return;
devServer.moduleGraph.invalidateModule(mod);
devServer.ws.send({ type: "full-reload" });
};
return {
name: "vite-plugin-shopify-theme-islands",
enforce: "pre",
configResolved(config) {
inventory.configure({
root: config.root,
aliases: config.resolve.alias
});
},
configureServer(server) {
devServer = server;
},
buildStart() {
const t0 = performance.now();
const snapshot = inventory.scan();
if (!snapshot)
return;
if (debug) {
const scanMs = (performance.now() - t0).toFixed(1);
log(`Scanned in ${scanMs}ms`);
log("Scanning directories:", snapshot.resolvedDirectories.map((dir) => dir + "**/*.{ts,js}").join(", "));
if (snapshot.directoryTagNames.length) {
log(`Found ${snapshot.directoryTagNames.length} directory island(s): [${snapshot.directoryTagNames.join(", ")}]`);
}
if (snapshot.islandFiles.length) {
const root = inventory.getRoot();
log(`Found ${snapshot.islandFiles.length} island file(s) via mixin import:`);
for (const file of snapshot.islandFiles)
log(" ", relative2(root, file));
}
log("Directives:", directives);
}
},
transform(code, id) {
const change = inventory.applyTransform(id, code);
if (!change)
return;
const root = inventory.getRoot();
log(change.type === "detected" ? "Detected island:" : "Removed island:", relative2(root, change.file));
},
watchChange(id, { event }) {
const change = inventory.applyWatchChange(id, event);
if (!change)
return;
const root = inventory.getRoot();
const prefix = event === "delete" ? "Removed island (deleted):" : change.type === "detected" ? "Detected island (watchChange):" : "Removed island (watchChange):";
log(prefix, relative2(root, change.file));
invalidateReviveModule();
},
resolveId(id) {
if (id === VIRTUAL_ID)
return RESOLVED_ID;
if (id === ISLAND_ID)
return islandPath;
},
async load(id) {
if (id !== RESOLVED_ID)
return;
const compiler = createReviveBootstrapCompiler({
resolveEntrypoint: async (entrypoint) => {
const resolved = await this.resolve(entrypoint);
if (!resolved) {
throw new Error(`[vite-plugin-shopify-theme-islands] Cannot resolve custom directive entrypoint: "${entrypoint}"`);
}
return resolved.id;
},
toLoadPaths: getIslandPathsForLoad
}, runtimePath);
const plan = await compiler.plan({
...inventory.getBootstrapState(),
customDirectives: clientDirectiveDefinitions,
reviveOptions
});
return compiler.emit(plan);
}
};
}
export {
isInteractionEventName,
shopifyThemeIslands as default,
INTERACTION_EVENT_NAMES,
DEFAULT_INTERACTION_EVENTS
};