vite-plugin-shopify-theme-islands
Version:
Vite plugin for island architecture in Shopify themes
1,038 lines (1,027 loc) • 37 kB
JavaScript
// 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
};