UNPKG

vite-plugin-shopify-theme-islands

Version:
236 lines (235 loc) 9.07 kB
// 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 };