UNPKG

vite-plugin-shopify-theme-islands

Version:
362 lines (355 loc) 13 kB
// src/index.ts import { readFileSync as readFileSync2 } from "node:fs"; import { join as join2, 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 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; } // 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: ["mouseenter", "touchstart", "focusin"] } }; var DEFAULT_RETRY = { retries: 0, delay: 1000 }; function normalizeReviveOptions(options) { const d = DEFAULT_DIRECTIVES; const r = DEFAULT_RETRY; const dir = options?.directives; 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 PREFIX = "[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(`${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); } } 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; return { runtimePath, directoryGlobs: input.directories.map((dir) => dir + "**/*.{ts,js}"), 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 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 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) : () => {}; let resolvedDirs = rawDirs; let root = process.cwd(); let absDirs = rawDirs; const islandFiles = new Set; let scanned = false; 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 : join2(root, d.replace(/^\//, ""))); }, buildStart() { if (scanned) return; scanned = true; const t0 = performance.now(); const initial = discoverIslandFiles(root, absDirs); islandFiles.clear(); initial.forEach((f) => islandFiles.add(f)); if (debug) { const scanMs = (performance.now() - t0).toFixed(1); log(`Scanned in ${scanMs}ms`); log("Scanning directories:", resolvedDirs.map((d) => d + "**/*.{ts,js}").join(", ")); const dirNames = absDirs.flatMap((dir) => collectTagNames(dir)); 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(" ", relative2(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, absDirs)) { islandFiles.add(id); log("Detected island:", relative2(root, id)); } else { if (islandFiles.delete(id)) log("Removed island:", relative2(root, id)); } }, watchChange(id, { event }) { if (!TS_JS_RE.test(id)) return; if (event === "delete") { if (islandFiles.delete(id)) log("Removed island (deleted):", relative2(root, id)); } else { try { const content = readFileSync2(id, "utf-8"); if (ISLAND_IMPORT_RE.test(content) && !inDirectory(id, absDirs)) { islandFiles.add(id); log("Detected island (watchChange):", relative2(root, id)); } else { if (islandFiles.delete(id)) log("Removed island (watchChange):", relative2(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 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({ root, directories: resolvedDirs, islandFiles, customDirectives: clientDirectiveDefinitions, reviveOptions }); return compiler.emit(plan); } }; } export { shopifyThemeIslands as default };