UNPKG

vite-plugin-shopify-theme-islands

Version:
458 lines (450 loc) 16.2 kB
// 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 };