UNPKG

vite-plugin-shopify-theme-islands

Version:
1,038 lines (1,027 loc) 37 kB
// src/index.ts import { relative as relative2 } from "node:path"; // 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 }; } function formatUnsupportedInteractionTokenWarning(params) { const { attribute, invalidTokens, usedDefaultEvents } = params; const countSuffix = invalidTokens.length === 1 ? "" : "s"; const invalidTokenList = invalidTokens.join(", "); if (!usedDefaultEvents) { return `${attribute} contains unsupported event token${countSuffix} (${invalidTokenList}) — ignoring invalid token${countSuffix}; supported tokens: ${INTERACTION_EVENT_NAMES_LABEL}`; } return `${attribute} contains no supported event tokens (${invalidTokenList}) — using default events; supported tokens: ${INTERACTION_EVENT_NAMES_LABEL}`; } // 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 deriveDefaultTag(key) { const filename = basename(key); return filename.replace(/\.(ts|js)$/, ""); } function defaultKeyToTag(key) { const filename = basename(key); const tag = deriveDefaultTag(key); 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 duplicateTagOwnershipError(tag, filePaths) { return new Error(`[islands] Multiple island entrypoints resolve to <${tag}>: - ${filePaths.join(` - `)} Tag ownership must be unique before calling revive(...). Remove one entry or disambiguate the final tag.`); } function compileResolvedTags(filePaths, resolveTag) { const entries = []; for (const filePath of filePaths) { const defaultTag = deriveDefaultTag(filePath); const resolvedTag = resolveTag({ filePath, defaultTag }); if (resolvedTag === defaultTag) continue; entries.push([filePath, resolvedTag]); } return entries.length > 0 ? Object.fromEntries(entries) : null; } function buildIslandMap(payload) { const map = new Map; const sourceKeys = new Map; for (const [key, loader] of Object.entries(payload.islands)) { const resolvedTag = payload.resolvedTags?.[key]; const { tag, skip } = resolvedTag !== undefined ? resolvedTag === false ? { tag: "", skip: true } : { tag: resolvedTag } : defaultKeyToTag(key); if (skip) continue; if (!map.has(tag)) { map.set(tag, loader); sourceKeys.set(tag, key); continue; } throw duplicateTagOwnershipError(tag, [sourceKeys.get(tag) ?? key, key]); } return map; } // src/directive-spine.ts function parseStrictIntegerAttribute(value, fallback) { if (value === null) return { value: null, invalid: false }; if (value === "") return { value: fallback, invalid: false }; const trimmed = value.trim(); if (!/^-?\d+$/.test(trimmed)) return { value: fallback, invalid: true }; return { value: Number.parseInt(trimmed, 10), invalid: false }; } function buildGatePlan(gates) { const customGates = []; const warnings = []; const initialDiagnosticParts = []; for (const gate of gates) { switch (gate.kind) { case "visible": { const part = gate.rawValue ? `${gate.attribute}="${gate.rawValue}"` : gate.attribute; initialDiagnosticParts.push(part); break; } case "media": { if (gate.rawValue) initialDiagnosticParts.push(`${gate.attribute}="${gate.rawValue}"`); if (gate.query === null) warnings.push({ kind: "emptyMediaQuery", attribute: gate.attribute }); break; } case "idle": { const part = gate.rawValue ? `${gate.attribute}="${gate.rawValue}"` : gate.attribute; initialDiagnosticParts.push(part); if (gate.invalid) warnings.push({ kind: "invalidIdleValue", attribute: gate.attribute, rawValue: gate.rawValue, defaultMs: gate.timeout }); break; } case "defer": { const part = gate.rawValue ? `${gate.attribute}="${gate.rawValue}"` : gate.attribute; initialDiagnosticParts.push(part); if (gate.invalid) warnings.push({ kind: "invalidDeferValue", attribute: gate.attribute, rawValue: gate.rawValue, defaultMs: gate.delay }); break; } case "interaction": { const part = gate.rawValue ? `${gate.attribute}="${gate.rawValue}"` : gate.attribute; initialDiagnosticParts.push(part); if (gate.emptyTokens) { warnings.push({ kind: "emptyInteractionTokens", attribute: gate.attribute }); } else if (gate.invalidTokens.length > 0) { warnings.push({ kind: "invalidInteractionTokens", attribute: gate.attribute, invalidTokens: gate.invalidTokens, usedDefaultEvents: gate.usedDefaultEvents }); } break; } case "custom": { const part = gate.value ? `${gate.attribute}="${gate.value}"` : gate.attribute; initialDiagnosticParts.push(part); customGates.push(gate); break; } } } return { gates, customGates, conflictSignature: describeGates(gates), initialDiagnosticParts, warnings }; } function formatEffectiveGate(gate) { switch (gate.kind) { case "visible": return `${gate.attribute}="${gate.rootMargin}"`; case "media": return gate.query ? `${gate.attribute}="${gate.query}"` : null; case "idle": return `${gate.attribute}="${gate.timeout}"`; case "defer": return `${gate.attribute}="${gate.delay}"`; case "interaction": return `${gate.attribute}="${gate.events.join(" ")}"`; case "custom": return gate.value ? `${gate.attribute}="${gate.value}"` : gate.attribute; } } function describeGates(gates) { if (gates.length === 0) return "immediate"; return gates.map(formatEffectiveGate).filter((part) => part !== null).join(", "); } function createDirectiveSpine(directives = DEFAULT_DIRECTIVES) { const attributeNames = new Set([ directives.visible.attribute, directives.idle.attribute, directives.media.attribute, directives.defer.attribute, directives.interaction.attribute ]); return { planGates(el) { return buildGatePlan(this.readGates(el)); }, readGates(el) { const gates = []; const visible = el.getAttribute(directives.visible.attribute); if (visible !== null) { gates.push({ kind: "visible", attribute: directives.visible.attribute, rawValue: visible, rootMargin: visible || directives.visible.rootMargin, threshold: directives.visible.threshold }); } const media = el.getAttribute(directives.media.attribute); if (media !== null) { gates.push({ kind: "media", attribute: directives.media.attribute, rawValue: media, query: media || null }); } const idle = parseStrictIntegerAttribute(el.getAttribute(directives.idle.attribute), directives.idle.timeout); if (idle.value !== null) { gates.push({ kind: "idle", attribute: directives.idle.attribute, timeout: idle.value, invalid: idle.invalid, rawValue: el.getAttribute(directives.idle.attribute) ?? "" }); } const defer = parseStrictIntegerAttribute(el.getAttribute(directives.defer.attribute), directives.defer.delay); if (defer.value !== null) { gates.push({ kind: "defer", attribute: directives.defer.attribute, delay: defer.value, invalid: defer.invalid, rawValue: el.getAttribute(directives.defer.attribute) ?? "" }); } const interaction = el.getAttribute(directives.interaction.attribute); if (interaction !== null) { let events = [...directives.interaction.events]; let invalidTokens = []; let emptyTokens = false; let usedDefaultEvents = interaction === ""; if (interaction) { const tokens = interaction.split(/\s+/).filter(Boolean); if (tokens.length === 0) { emptyTokens = true; usedDefaultEvents = true; } else { const partition = partitionInteractionEventTokens(tokens); invalidTokens = partition.invalid; if (partition.valid.length > 0) { events = partition.valid; usedDefaultEvents = false; } else { usedDefaultEvents = true; } } } gates.push({ kind: "interaction", attribute: directives.interaction.attribute, rawValue: interaction, events, invalidTokens, emptyTokens, usedDefaultEvents }); } return gates; }, describe(el) { return describeGates(this.readGates(el)); }, attributeNames }; } function extendDirectiveSpine(base, customDirectives) { if (!customDirectives?.size) return base; const attributeNames = new Set(base.attributeNames); for (const attrName of customDirectives.keys()) attributeNames.add(attrName); return { readGates(el) { const gates = [...base.readGates(el)]; for (const [attribute, directive] of customDirectives) { const value = el.getAttribute(attribute); if (value !== null) { gates.push({ kind: "custom", attribute, value, directive }); } } return gates; }, planGates(el) { return buildGatePlan(this.readGates(el)); }, describe(el) { return describeGates(this.readGates(el)); }, attributeNames }; } var DEFAULT_DIRECTIVE_SPINE = createDirectiveSpine(); // src/resolved-config.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.tagSource !== undefined && options.tagSource !== "registeredTag" && options.tagSource !== "filename") { throw new Error(`${PREFIX2} "tagSource" must be "registeredTag" or "filename"`); } 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 = createDirectiveSpine(directives).attributeNames; 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 resolveThemeIslandsConfig(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, debug }, runtimeOptions: () => runtime, compileInputs: (input) => ({ ...input, tagSource: options.tagSource ?? "registeredTag", resolveTag: options.resolveTag, customDirectives, reviveOptions: runtime }) }; } var compileThemeIslandsConfig = resolveThemeIslandsConfig; // 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 isIslandMember(code, absolutePath, absDirs) { return ISLAND_IMPORT_RE.test(code) && !inDirectory(absolutePath, absDirs); } function discoverIslandFiles(root, absDirs) { const found = new Set; walkDir(root, (_, full) => { try { if (isIslandMember(readFileSync(full, "utf-8"), full, absDirs)) found.add(full); } catch {} }); return found; } function collectTagNames(dir) { const names = []; walkDir(dir, (name) => names.push(name.replace(TS_JS_RE, ""))); return names; } function collectFiles(dir) { const files = []; walkDir(dir, (_, full) => files.push(full)); return files; } function createIslandInventory(rawDirectories) { let root = process.cwd(); let resolvedDirs = [...rawDirectories]; let absDirs = [...rawDirectories]; const directoryFiles = new Set; const islandFiles = new Set; let scanned = false; const buildSnapshot = () => ({ resolvedDirectories: [...resolvedDirs], directoryFiles: [...directoryFiles], islandFiles: [...islandFiles], directoryTagNames: absDirs.flatMap((dir) => collectTagNames(dir)) }); const updateIslandFile = (id, code) => { if (!TS_JS_RE.test(id)) return null; if (isIslandMember(code, 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; }; const ensureScanned = () => { if (scanned) return null; scanned = true; directoryFiles.clear(); absDirs.flatMap((dir) => collectFiles(dir)).forEach((file) => directoryFiles.add(file)); islandFiles.clear(); discoverIslandFiles(root, absDirs).forEach((file) => islandFiles.add(file)); return buildSnapshot(); }; return { configure(config) { root = config.root; resolvedDirs = resolveAliases(rawDirectories, config.aliases); absDirs = toAbsoluteDirs(root, resolvedDirs); }, scan() { return ensureScanned(); }, applyTransform(id, code) { return updateIslandFile(id, code); }, applyWatchChange(id, event) { if (!TS_JS_RE.test(id)) return null; if (inDirectory(id, absDirs)) { if (event === "delete") { return directoryFiles.delete(id) ? { type: "removed", file: id } : null; } if (!directoryFiles.has(id)) { directoryFiles.add(id); return { type: "detected", file: 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; } }, state() { ensureScanned(); return { root, directories: [...resolvedDirs], directoryFiles: new Set(directoryFiles), islandFiles: new Set(islandFiles) }; }, getRoot() { return root; } }; } // src/revive-compile.ts import { readFileSync as readFileSync2 } from "node:fs"; // src/revive-module.ts function buildReviveModuleSource(params) { const { runtimePath, directoryGlobs, islandPaths, resolvedTags, 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(`, `)} ]);`); if (resolvedTags) lines.push(`const resolvedTags = ${JSON.stringify(resolvedTags)};`); lines.push(resolvedTags ? `const payload = { islands, options, customDirectives, resolvedTags };` : `const payload = { islands, options, customDirectives };`); } else { if (resolvedTags) lines.push(`const resolvedTags = ${JSON.stringify(resolvedTags)};`); lines.push(resolvedTags ? `const payload = { islands, options, resolvedTags };` : `const payload = { islands, options };`); } lines.push(`const runtimeKey = "__shopify_theme_islands_runtime__";`); lines.push(`const runtimeState = (globalThis[runtimeKey] ??= {});`); lines.push(`const runtime = runtimeState.runtime ?? _islands(payload);`); lines.push(`runtimeState.runtime = runtime;`); lines.push(`if (import.meta.hot) {`); lines.push(` import.meta.hot.accept();`); lines.push(` import.meta.hot.dispose(() => {`); lines.push(` if (runtimeState.runtime === runtime) {`); lines.push(` runtime.disconnect();`); lines.push(` delete runtimeState.runtime;`); lines.push(` }`); lines.push(` });`); lines.push(`}`); lines.push(`export const { disconnect, scan, observe, unobserve } = runtime;`); return lines.join(` `); } // src/tag-ownership.ts class StaticDefinedTagScanner { content; cursor = 0; constructor(content) { this.content = content; } scan() { const tags = []; while (this.cursor < this.content.length) { if (this.skipComment() || this.skipQuotedString() || this.skipTemplateLiteral()) continue; const tag = this.readStaticDefinedTag(); if (tag !== null) { tags.push(tag); continue; } this.cursor += 1; } return tags; } skipComment() { if (this.peek() !== "/") return false; if (this.peek(1) === "/") { this.cursor += 2; while (this.cursor < this.content.length && this.peek() !== ` `) this.cursor += 1; return true; } if (this.peek(1) !== "*") return false; this.cursor += 2; while (this.cursor < this.content.length && !(this.peek() === "*" && this.peek(1) === "/")) { this.cursor += 1; } this.cursor = Math.min(this.cursor + 2, this.content.length); return true; } skipQuotedString() { const quote = this.peek(); if (quote !== "'" && quote !== '"') return false; this.cursor += 1; while (this.cursor < this.content.length) { if (this.peek() === "\\") { this.cursor += 2; continue; } if (this.peek() === quote) { this.cursor += 1; return true; } this.cursor += 1; } return true; } skipTemplateLiteral() { if (this.peek() !== "`") return false; this.cursor += 1; while (this.cursor < this.content.length) { if (this.peek() === "\\") { this.cursor += 2; continue; } if (this.peek() === "`") { this.cursor += 1; return true; } this.cursor += 1; } return true; } readStaticDefinedTag() { const prefix = "customElements.define"; if (!this.content.startsWith(prefix, this.cursor)) return null; if (this.isIdentifierChar(this.peek(-1))) return null; let index = this.skipWhitespace(this.cursor + prefix.length); if (this.content[index] !== "(") return null; index = this.skipWhitespace(index + 1); const quote = this.content[index]; if (quote !== "'" && quote !== '"' && quote !== "`") return null; let tag = ""; index += 1; while (index < this.content.length) { const char = this.content[index]; if (char === "\\") return null; if (char === quote) break; tag += char; index += 1; } if (this.content[index] !== quote || !/^[a-z0-9-]+$/.test(tag)) return null; index = this.skipWhitespace(index + 1); if (this.content[index] !== ",") return null; this.cursor = index + 1; return tag; } skipWhitespace(start) { let index = start; while (index < this.content.length && /\s/.test(this.content[index])) index += 1; return index; } isIdentifierChar(char) { return char !== undefined && /[A-Za-z0-9_$]/.test(char); } peek(offset = 0) { return this.content[this.cursor + offset]; } } function readStaticDefinedTags(content) { return new StaticDefinedTagScanner(content).scan(); } function assertUniqueTagOwnership(records) { const filePathsByTag = new Map; for (const { filePath, resolvedTag } of records) { if (resolvedTag === false) continue; const filePaths = filePathsByTag.get(resolvedTag) ?? []; filePaths.push(filePath); filePathsByTag.set(resolvedTag, filePaths); } for (const [tag, filePaths] of filePathsByTag) { if (filePaths.length < 2) continue; throw new Error(`[vite-plugin-shopify-theme-islands] Multiple island entrypoints resolve to <${tag}>: - ${filePaths.join(` - `)} Tag ownership must be unique at compile time. Rename one file, adjust resolveTag({ filePath, defaultTag }), or return false to exclude one file.`); } } function warnOnTagMismatch(filePath, resolvedTag, definedTag) { console.warn(`[vite-plugin-shopify-theme-islands] ${filePath} resolves to <${resolvedTag}> but statically registers <${definedTag}> via customElements.define(...). Tag ownership is path-based, so update the filename/resolveTag() or the registered tag so they match.`); } function analyzeTagOwnership(inputs) { const { files, tagSource, resolveTag, getFileContent } = inputs; const records = files.map(({ absoluteFilePath, filePath }) => { let defaultTag; if (tagSource === "registeredTag") { const content = getFileContent(absoluteFilePath); const tags = content ? readStaticDefinedTags(content) : []; if (tags.length === 0) { throw new Error(`[vite-plugin-shopify-theme-islands] ${filePath}: no static customElements.define("...", ...) found. In registeredTag mode this plugin requires exactly one static Registered Tag per Island file so Tag ownership and lazy-load boundaries stay unambiguous. Add customElements.define("your-tag", ...) or switch to tagSource: "filename".`); } if (tags.length > 1) { throw new Error(`[vite-plugin-shopify-theme-islands] ${filePath}: found ${tags.length} static customElements.define(...) calls (${tags.map((t) => `<${t}>`).join(", ")}). In registeredTag mode this plugin requires exactly one Registered Tag per Island file so Tag ownership and lazy-load boundaries stay unambiguous.`); } defaultTag = tags[0]; } else { defaultTag = deriveDefaultTag(filePath); } const resolvedTag = resolveTag ? resolveTag({ filePath, defaultTag }) : defaultTag; return { absoluteFilePath, filePath, defaultTag, resolvedTag }; }); assertUniqueTagOwnership(records); if (tagSource === "filename") { for (const { absoluteFilePath, filePath, resolvedTag } of records) { if (resolvedTag === false) continue; const content = getFileContent(absoluteFilePath); const definedTag = content ? readStaticDefinedTags(content)[0] ?? null : null; if (definedTag && definedTag !== resolvedTag) { warnOnTagMismatch(filePath, resolvedTag, definedTag); } } } return records; } function recomputeFileTagOwnership(absoluteFilePath, filePath, inputs) { const { tagSource, resolveTag, getFileContent } = inputs; if (tagSource === "filename") return null; const content = getFileContent(absoluteFilePath); const tags = content ? readStaticDefinedTags(content) : []; if (tags.length !== 1) return null; const defaultTag = tags[0]; return resolveTag ? resolveTag({ filePath, defaultTag }) : defaultTag; } // src/revive-compile.ts function getFileContent(ports, filePath) { if (ports.readFile) return ports.readFile(filePath); try { return readFileSync2(filePath, "utf-8"); } catch { return null; } } function createReviveCompiler(ports, runtimePath) { return { async plan(input, resolvePorts) { const tagSource = input.tagSource ?? "registeredTag"; const absoluteFiles = [...new Set([...input.directoryFiles, ...input.islandFiles])]; const filePaths = ports.toLoadPaths(new Set(absoluteFiles), input.root); const files = absoluteFiles.map((absoluteFilePath, index) => ({ absoluteFilePath, filePath: filePaths[index] })); const records = analyzeTagOwnership({ files, tagSource, resolveTag: input.resolveTag, getFileContent: (path) => getFileContent(ports, path) }); const islandPaths = input.islandFiles.size > 0 ? ports.toLoadPaths(input.islandFiles, input.root) : null; const resolvedTags = (() => { if (tagSource === "registeredTag") { const entries = []; for (const { filePath, resolvedTag } of records) { if (resolvedTag !== deriveDefaultTag(filePath)) entries.push([filePath, resolvedTag]); } return entries.length > 0 ? Object.fromEntries(entries) : null; } const resolvedTagByFilePath = new Map(records.map(({ filePath, resolvedTag }) => [filePath, resolvedTag])); return input.resolveTag ? compileResolvedTags(filePaths, ({ filePath, defaultTag }) => resolvedTagByFilePath.get(filePath) ?? defaultTag) : null; })(); const customDirectives = input.customDirectives?.length ? await (() => { if (!resolvePorts) { throw new Error("[vite-plugin-shopify-theme-islands] resolveEntrypoint is required when custom directives are configured"); } return Promise.all(input.customDirectives.map(async ({ name, entrypoint }) => ({ name, entrypoint: await resolvePorts.resolveEntrypoint(entrypoint) }))); })() : null; const directoryGlobs = input.directories.map((dir) => dir + "**/*.{ts,js}"); const ownershipMap = tagSource === "registeredTag" ? new Map(records.map(({ absoluteFilePath, resolvedTag }) => [absoluteFilePath, resolvedTag])) : new Map; return { runtimePath, directoryGlobs, islandPaths, resolvedTags, customDirectives, reviveOptions: input.reviveOptions, ownershipMap }; }, emit(plan) { return buildReviveModuleSource({ runtimePath: plan.runtimePath, directoryGlobs: plan.directoryGlobs, islandPaths: plan.islandPaths, resolvedTags: plan.resolvedTags ?? undefined, customDirectives: plan.customDirectives?.length ? plan.customDirectives : undefined, reviveOptions: plan.reviveOptions }); }, async compile(input, resolvePorts) { const plan = await this.plan(input, resolvePorts); return this.emit(plan); }, recomputeOwnership(absoluteFilePath, filePath, input) { return recomputeFileTagOwnership(absoluteFilePath, filePath, { tagSource: input.tagSource ?? "registeredTag", resolveTag: input.resolveTag, getFileContent: (path) => getFileContent(ports, path) }); } }; } // 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 config = compileThemeIslandsConfig(options); const { directives, debug } = config.plugin; const log = debug ? (...args) => console.log("[islands]", ...args) : () => {}; const inventory = createIslandInventory(rawDirs); const compiler = createReviveCompiler({ toLoadPaths: getIslandPathsForLoad }, runtimePath); let devServer = null; const ownershipSnapshot = new Map; const invalidateReviveModule = () => { if (!devServer) return; const mod = devServer.moduleGraph.getModuleById(RESOLVED_ID); if (!mod) return; devServer.moduleGraph.invalidateModule(mod); if (typeof devServer.reloadModule === "function") { devServer.reloadModule(mod); return; } devServer.ws.send({ type: "full-reload" }); }; return { name: "vite-plugin-shopify-theme-islands", enforce: "pre", configResolved(config2) { inventory.configure({ root: config2.root, aliases: config2.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) { 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(); return; } if (event === "update" && ownershipSnapshot.has(id)) { const compileInputs = config.compileInputs(inventory.state()); const loadPath = "/" + relative2(inventory.getRoot(), id).replace(/\\/g, "/"); const newTag = compiler.recomputeOwnership(id, loadPath, compileInputs); if (newTag === null || newTag !== ownershipSnapshot.get(id)) { if (newTag !== null) ownershipSnapshot.set(id, newTag); 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 plan = await compiler.plan(config.compileInputs(inventory.state()), { 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; } }); ownershipSnapshot.clear(); for (const [absPath, tag] of plan.ownershipMap) { ownershipSnapshot.set(absPath, tag); } return compiler.emit(plan); } }; } export { isInteractionEventName, shopifyThemeIslands as default, INTERACTION_EVENT_NAMES, DEFAULT_INTERACTION_EVENTS };