vite-plugin-shopify-theme-islands 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Plugin ↔ Runtime contract (deep module).
3
+ *
4
+ * Single source of truth for the payload shape, key→tag derivation, and defaults.
5
+ * Plugin and runtime both depend on this module in-process.
6
+ */
7
+ /** Loader function for one island chunk. */
8
+ export type IslandLoader = () => Promise<unknown>;
9
+ /** Directive config for the runtime (built-in + no plugin-only `custom` entrypoints). */
10
+ export interface RuntimeDirectivesConfig {
11
+ visible?: {
12
+ attribute?: string;
13
+ rootMargin?: string;
14
+ threshold?: number;
15
+ };
16
+ idle?: {
17
+ attribute?: string;
18
+ timeout?: number;
19
+ };
20
+ media?: {
21
+ attribute?: string;
22
+ };
23
+ defer?: {
24
+ attribute?: string;
25
+ delay?: number;
26
+ };
27
+ interaction?: {
28
+ attribute?: string;
29
+ events?: string[];
30
+ };
31
+ }
32
+ /** Retry configuration. */
33
+ export interface RetryConfig {
34
+ retries?: number;
35
+ delay?: number;
36
+ }
37
+ /** Options passed from plugin to runtime (subset of plugin options). */
38
+ export interface ReviveOptions {
39
+ directives?: RuntimeDirectivesConfig;
40
+ debug?: boolean;
41
+ retry?: RetryConfig;
42
+ }
43
+ /** Options passed to a custom client directive function. */
44
+ export interface ClientDirectiveOptions {
45
+ /** The matched attribute name, e.g. `'client:on-click'` */
46
+ name: string;
47
+ /** The attribute value; empty string if no value was set */
48
+ value: string;
49
+ }
50
+ /** Custom directive function at runtime (load, opts, element). */
51
+ export type ClientDirective = (load: () => Promise<void>, options: ClientDirectiveOptions, el: HTMLElement) => void | Promise<void>;
52
+ /** Event detail for the `islands:load` DOM event. */
53
+ export interface IslandLoadDetail {
54
+ /** The custom element tag name, e.g. `'product-form'` */
55
+ tag: string;
56
+ /** Milliseconds from directive resolution to successful module load (chunk fetch time). */
57
+ duration: number;
58
+ /** Which attempt succeeded. 1 = first try, 2 = first retry, etc. */
59
+ attempt: number;
60
+ }
61
+ /** Event detail for the `islands:error` DOM event. */
62
+ export interface IslandErrorDetail {
63
+ /** The custom element tag name, e.g. `'product-form'` */
64
+ tag: string;
65
+ /** The error thrown by the loader or custom directive */
66
+ error: unknown;
67
+ /** Which attempt failed. 1 = initial attempt, 2 = first retry, etc. */
68
+ attempt: number;
69
+ }
70
+ declare global {
71
+ interface DocumentEventMap {
72
+ "islands:load": CustomEvent<IslandLoadDetail>;
73
+ "islands:error": CustomEvent<IslandErrorDetail>;
74
+ }
75
+ }
76
+ /**
77
+ * Payload the plugin emits and the runtime consumes.
78
+ * Islands: glob key → loader (e.g. "/frontend/js/islands/product-form.ts" → loader).
79
+ * Custom directives: attribute name → directive implementation (resolved at build).
80
+ * Options may be partial; runtime uses normalizeReviveOptions() to fill defaults.
81
+ */
82
+ export interface RevivePayload {
83
+ islands: Record<string, IslandLoader>;
84
+ options?: ReviveOptions;
85
+ customDirectives?: Map<string, ClientDirective>;
86
+ }
87
+ /** Fully resolved options; all directive and retry fields have defaults applied. */
88
+ export interface NormalizedReviveOptions {
89
+ directives: {
90
+ visible: {
91
+ attribute: string;
92
+ rootMargin: string;
93
+ threshold: number;
94
+ };
95
+ idle: {
96
+ attribute: string;
97
+ timeout: number;
98
+ };
99
+ media: {
100
+ attribute: string;
101
+ };
102
+ defer: {
103
+ attribute: string;
104
+ delay: number;
105
+ };
106
+ interaction: {
107
+ attribute: string;
108
+ events: string[];
109
+ };
110
+ };
111
+ debug: boolean;
112
+ retry: {
113
+ retries: number;
114
+ delay: number;
115
+ };
116
+ }
117
+ /** Default directive config. Single source of truth for plugin merge and runtime normalization. */
118
+ export declare const DEFAULT_DIRECTIVES: NormalizedReviveOptions["directives"];
119
+ /**
120
+ * Applies default values for all runtime options.
121
+ * Single source of truth so plugin and runtime do not duplicate defaults.
122
+ */
123
+ export declare function normalizeReviveOptions(options?: ReviveOptions): NormalizedReviveOptions;
124
+ export type KeyToTagResult = {
125
+ tag: string;
126
+ skip?: boolean;
127
+ };
128
+ /**
129
+ * Maps a glob key (e.g. "/frontend/js/islands/product-form.ts") to a custom element tag.
130
+ * Return { tag, skip: true } to exclude this entry from the island map.
131
+ */
132
+ export type KeyToTagFn = (key: string) => KeyToTagResult;
133
+ /** Default: last path segment, extension stripped; skip (and warn) when tag has no hyphen. */
134
+ export declare function defaultKeyToTag(key: string): KeyToTagResult;
135
+ /**
136
+ * Builds tag → loader map from payload.
137
+ * Applies the default key→tag derivation and deduplicates by tag (first wins).
138
+ */
139
+ export declare function buildIslandMap(payload: RevivePayload): Map<string, IslandLoader>;
@@ -0,0 +1,12 @@
1
+ /** Matches .ts or .js extension. Exported for plugin transform/watch filters. */
2
+ export declare const TS_JS_RE: RegExp;
3
+ /** Matches the island mixin import. Exported for plugin transform/watch detection. */
4
+ export declare const ISLAND_IMPORT_RE: RegExp;
5
+ /** True if file is under any of the given absolute directory paths. */
6
+ export declare function inDirectory(file: string, absDirs: string[]): boolean;
7
+ /** Paths for load() virtual module: "/relative/to/root" form, forward slashes. */
8
+ export declare function getIslandPathsForLoad(islandFiles: Set<string>, root: string): string[];
9
+ /** Scan from root for files containing the island import; returns paths (not in absDirs). */
10
+ export declare function discoverIslandFiles(root: string, absDirs: string[]): Set<string>;
11
+ /** Tag names (filename without extension) for TS/JS files in a directory. Used for debug logging. */
12
+ export declare function collectTagNames(dir: string): string[];
package/dist/index.d.ts CHANGED
@@ -1,45 +1,7 @@
1
1
  import type { Plugin } from "vite";
2
2
  /** A function that triggers the load of an island module. */
3
3
  export type ClientDirectiveLoader = () => Promise<void>;
4
- /** Options passed to a custom client directive function. */
5
- export interface ClientDirectiveOptions {
6
- /** The matched attribute name, e.g. `'client:on-click'` */
7
- name: string;
8
- /** The attribute value; empty string if no value was set */
9
- value: string;
10
- }
11
- /**
12
- * A custom client directive function.
13
- *
14
- * Called by the runtime when a matching attribute is found on an island element.
15
- * The function is responsible for calling `load()` when the desired condition is met.
16
- *
17
- * @example
18
- * ```ts
19
- * // src/directives/hash.ts
20
- * import type { ClientDirective } from 'vite-plugin-shopify-theme-islands';
21
- *
22
- * const hashDirective: ClientDirective = (load, opts) => {
23
- * const target = opts.value;
24
- * if (location.hash === target) { load(); return; }
25
- * window.addEventListener('hashchange', () => {
26
- * if (location.hash === target) load();
27
- * });
28
- * };
29
- *
30
- * export default hashDirective;
31
- * ```
32
- *
33
- * Register it in `vite.config.ts`:
34
- * ```ts
35
- * shopifyThemeIslands({
36
- * directives: {
37
- * custom: [{ name: 'client:hash', entrypoint: './src/directives/hash.ts' }],
38
- * },
39
- * })
40
- * ```
41
- */
42
- export type ClientDirective = (load: ClientDirectiveLoader, options: ClientDirectiveOptions, el: HTMLElement) => void | Promise<void>;
4
+ export type { ClientDirective, ClientDirectiveOptions } from "./contract.js";
43
5
  /** Plugin option entry for registering a custom client directive. */
44
6
  export interface ClientDirectiveDefinition {
45
7
  /** HTML attribute name, e.g. `'client:on-click'` */
@@ -87,41 +49,9 @@ export interface DirectivesConfig {
87
49
  /** Custom client directives to register. Each entry maps an attribute name to a module entrypoint. */
88
50
  custom?: ClientDirectiveDefinition[];
89
51
  }
90
- /** Runtime-facing directive configuration omits plugin-only `custom` directives. */
91
- export type RuntimeDirectivesConfig = Omit<DirectivesConfig, "custom">;
92
- /** Retry configuration for failed island loads. */
93
- export interface RetryConfig {
94
- /** Number of times to retry after the initial failure. Default: `0` (no auto-retry) */
95
- retries?: number;
96
- /** Base delay in ms between retries; doubles each attempt. Default: `1000` */
97
- delay?: number;
98
- }
99
- /** Event detail for the `islands:load` DOM event. */
100
- export interface IslandLoadDetail {
101
- /** The custom element tag name, e.g. `'product-form'` */
102
- tag: string;
103
- /** Milliseconds from directive resolution to successful module load (chunk fetch time). */
104
- duration: number;
105
- /** Which attempt succeeded. 1 = first try, 2 = first retry, etc. */
106
- attempt: number;
107
- }
108
- /** Event detail for the `islands:error` DOM event. */
109
- export interface IslandErrorDetail {
110
- /** The custom element tag name, e.g. `'product-form'` */
111
- tag: string;
112
- /** The error thrown by the loader or custom directive */
113
- error: unknown;
114
- /** Which attempt failed. 1 = initial attempt, 2 = first retry, etc. */
115
- attempt: number;
116
- }
117
- declare global {
118
- interface DocumentEventMap {
119
- /** Fired after an island module resolves successfully. */
120
- "islands:load": CustomEvent<IslandLoadDetail>;
121
- /** Fired when an island load or custom directive fails. Fired on each retry attempt. */
122
- "islands:error": CustomEvent<IslandErrorDetail>;
123
- }
124
- }
52
+ /** Event detail and runtime options (single source of truth in contract). */
53
+ import type { RetryConfig } from "./contract.js";
54
+ export type { IslandLoadDetail, IslandErrorDetail, ReviveOptions, RetryConfig, RuntimeDirectivesConfig, } from "./contract.js";
125
55
  export interface ShopifyThemeIslandsOptions {
126
56
  /** Directories to scan for island files. Accepts paths or Vite aliases. Default: `['/frontend/js/islands/']` */
127
57
  directories?: string | string[];
@@ -132,11 +62,4 @@ export interface ShopifyThemeIslandsOptions {
132
62
  /** Automatic retry behaviour for failed island loads. */
133
63
  retry?: RetryConfig;
134
64
  }
135
- export interface ReviveOptions {
136
- directives?: RuntimeDirectivesConfig;
137
- /** Log island activation and directive events to the console. Default: `false` */
138
- debug?: boolean;
139
- /** Automatic retry behaviour for failed island loads. */
140
- retry?: RetryConfig;
141
- }
142
65
  export default function shopifyThemeIslands(options?: ShopifyThemeIslandsOptions): Plugin;
package/dist/index.js CHANGED
@@ -1,15 +1,147 @@
1
1
  // src/index.ts
2
+ import { readFileSync as readFileSync2 } from "node:fs";
3
+ import { join as join2, relative as relative2 } from "node:path";
4
+
5
+ // src/discovery.ts
2
6
  import { readFileSync, readdirSync } from "node:fs";
3
- import { join, relative } from "node:path";
7
+ import { isAbsolute, join, relative, resolve } from "node:path";
8
+ var TS_JS_RE = /\.(ts|js)$/;
9
+ var SKIP_DIRS = new Set(["node_modules", "dist", "build", "public", "assets", ".cache"]);
10
+ var ISLAND_IMPORT_RE = /from\s+['"]vite-plugin-shopify-theme-islands\/island['"]/;
11
+ function inDirectory(file, absDirs) {
12
+ const resolvedFile = resolve(file);
13
+ return absDirs.some((dir) => {
14
+ const rel = relative(resolve(dir), resolvedFile);
15
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
16
+ });
17
+ }
18
+ function getIslandPathsForLoad(islandFiles, root) {
19
+ return [...islandFiles].map((file) => "/" + relative(root, file).replace(/\\/g, "/"));
20
+ }
21
+ function walkDir(dir, visitor) {
22
+ let entries;
23
+ try {
24
+ entries = readdirSync(dir, { withFileTypes: true });
25
+ } catch {
26
+ return;
27
+ }
28
+ for (const entry of entries) {
29
+ if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
30
+ continue;
31
+ const full = join(dir, entry.name);
32
+ if (entry.isDirectory())
33
+ walkDir(full, visitor);
34
+ else if (TS_JS_RE.test(entry.name))
35
+ visitor(entry.name, full);
36
+ }
37
+ }
38
+ function discoverIslandFiles(root, absDirs) {
39
+ const found = new Set;
40
+ walkDir(root, (_, full) => {
41
+ try {
42
+ if (ISLAND_IMPORT_RE.test(readFileSync(full, "utf-8")))
43
+ found.add(full);
44
+ } catch {}
45
+ });
46
+ for (const f of [...found])
47
+ if (inDirectory(f, absDirs))
48
+ found.delete(f);
49
+ return found;
50
+ }
51
+ function collectTagNames(dir) {
52
+ const names = [];
53
+ walkDir(dir, (name) => names.push(name.replace(TS_JS_RE, "")));
54
+ return names;
55
+ }
56
+
57
+ // src/revive-module.ts
58
+ function buildReviveModuleSource(params) {
59
+ const { runtimePath, directoryGlobs, islandPaths, customDirectives, reviveOptions } = params;
60
+ const directiveImportLines = customDirectives?.map(({ entrypoint }, index) => `import _directive${index} from ${JSON.stringify(entrypoint)};`) ?? [];
61
+ const globEntries = [
62
+ `{ ${directoryGlobs.map((glob) => `...import.meta.glob(${JSON.stringify(glob)})`).join(", ")} }`
63
+ ];
64
+ if (islandPaths?.length)
65
+ globEntries.push(`import.meta.glob(${JSON.stringify(islandPaths)})`);
66
+ const lines = [
67
+ ...directiveImportLines,
68
+ `import { revive as _islands } from ${JSON.stringify(runtimePath)};`,
69
+ `const islands = Object.assign({}, ${globEntries.join(", ")});`,
70
+ `const options = ${JSON.stringify(reviveOptions)};`
71
+ ];
72
+ if (customDirectives?.length) {
73
+ const customDirectivesMapLines = customDirectives.map(({ name }, index) => ` [${JSON.stringify(name)}, _directive${index}]`);
74
+ lines.push(`const customDirectives = new Map([
75
+ ${customDirectivesMapLines.join(`,
76
+ `)}
77
+ ]);`);
78
+ lines.push(`const payload = { islands, options, customDirectives };`);
79
+ } else {
80
+ lines.push(`const payload = { islands, options };`);
81
+ }
82
+ lines.push(`export const { disconnect } = _islands(payload);`);
83
+ return lines.join(`
84
+ `);
85
+ }
86
+
87
+ // src/index.ts
4
88
  import { fileURLToPath } from "node:url";
89
+
90
+ // src/contract.ts
91
+ var DEFAULT_DIRECTIVES = {
92
+ visible: { attribute: "client:visible", rootMargin: "200px", threshold: 0 },
93
+ idle: { attribute: "client:idle", timeout: 500 },
94
+ media: { attribute: "client:media" },
95
+ defer: { attribute: "client:defer", delay: 3000 },
96
+ interaction: {
97
+ attribute: "client:interaction",
98
+ events: ["mouseenter", "touchstart", "focusin"]
99
+ }
100
+ };
101
+ var DEFAULT_RETRY = { retries: 0, delay: 1000 };
102
+ function normalizeReviveOptions(options) {
103
+ const d = DEFAULT_DIRECTIVES;
104
+ const r = DEFAULT_RETRY;
105
+ const dir = options?.directives;
106
+ return {
107
+ directives: {
108
+ visible: { ...d.visible, ...dir?.visible },
109
+ idle: { ...d.idle, ...dir?.idle },
110
+ media: { ...d.media, ...dir?.media },
111
+ defer: { ...d.defer, ...dir?.defer },
112
+ interaction: { ...d.interaction, ...dir?.interaction }
113
+ },
114
+ debug: options?.debug ?? false,
115
+ retry: { ...r, ...options?.retry }
116
+ };
117
+ }
118
+ var basename = (key) => key.split("/").pop() ?? key;
119
+ function defaultKeyToTag(key) {
120
+ const filename = basename(key);
121
+ const tag = filename.replace(/\.(ts|js)$/, "");
122
+ const skip = !tag.includes("-");
123
+ if (skip && tag)
124
+ console.warn(`[islands] Skipping "${filename}" — filename must contain a hyphen to match a valid custom element tag (e.g. rename to "${tag}-island.ts")`);
125
+ return { tag, skip };
126
+ }
127
+ function buildIslandMap(payload) {
128
+ const map = new Map;
129
+ for (const [key, loader] of Object.entries(payload.islands)) {
130
+ const { tag, skip } = defaultKeyToTag(key);
131
+ if (skip)
132
+ continue;
133
+ if (!map.has(tag))
134
+ map.set(tag, loader);
135
+ }
136
+ return map;
137
+ }
138
+
139
+ // src/index.ts
5
140
  var VIRTUAL_ID = "vite-plugin-shopify-theme-islands/revive";
6
141
  var RESOLVED_ID = "\x00" + VIRTUAL_ID;
7
142
  var ISLAND_ID = "vite-plugin-shopify-theme-islands/island";
8
143
  var runtimePath = fileURLToPath(new URL("./runtime.js", import.meta.url));
9
144
  var islandPath = fileURLToPath(new URL("./island.js", import.meta.url));
10
- var ISLAND_IMPORT_RE = /from\s+['"]vite-plugin-shopify-theme-islands\/island['"]/;
11
- var TS_JS_RE = /\.(ts|js)$/;
12
- var SKIP_DIRS = new Set(["node_modules", "dist", "build", "public", "assets", ".cache"]);
13
145
  var PREFIX = "[vite-plugin-shopify-theme-islands]";
14
146
  function validateOptions(options, directives) {
15
147
  const customDefs = options.directives?.custom ?? [];
@@ -47,19 +179,7 @@ function validateOptions(options, directives) {
47
179
  seen.add(def.name);
48
180
  }
49
181
  }
50
- var defaults = {
51
- directories: ["/frontend/js/islands/"],
52
- directives: {
53
- visible: { attribute: "client:visible", rootMargin: "200px", threshold: 0 },
54
- idle: { attribute: "client:idle", timeout: 500 },
55
- media: { attribute: "client:media" },
56
- defer: { attribute: "client:defer", delay: 3000 },
57
- interaction: {
58
- attribute: "client:interaction",
59
- events: ["mouseenter", "touchstart", "focusin"]
60
- }
61
- }
62
- };
182
+ var defaultDirectories = ["/frontend/js/islands/"];
63
183
  function normalizeDir(dir) {
64
184
  return dir.endsWith("/") ? dir : dir + "/";
65
185
  }
@@ -75,42 +195,14 @@ function resolveAliases(dirs, config) {
75
195
  return dir;
76
196
  });
77
197
  }
78
- function walkDir(dir, visitor) {
79
- let entries;
80
- try {
81
- entries = readdirSync(dir, { withFileTypes: true });
82
- } catch {
83
- return;
84
- }
85
- for (const entry of entries) {
86
- if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
87
- continue;
88
- const full = join(dir, entry.name);
89
- if (entry.isDirectory())
90
- walkDir(full, visitor);
91
- else if (TS_JS_RE.test(entry.name))
92
- visitor(entry.name, full);
93
- }
94
- }
95
- function collectTagNames(dir, names) {
96
- walkDir(dir, (name) => names.push(name.replace(TS_JS_RE, "")));
97
- }
98
- function scanForIslandFiles(dir, found) {
99
- walkDir(dir, (_, full) => {
100
- try {
101
- if (ISLAND_IMPORT_RE.test(readFileSync(full, "utf-8")))
102
- found.add(full);
103
- } catch {}
104
- });
105
- }
106
198
  function shopifyThemeIslands(options = {}) {
107
- const rawDirs = (Array.isArray(options.directories) ? options.directories : [options.directories ?? defaults.directories[0]]).map(normalizeDir);
199
+ const rawDirs = (Array.isArray(options.directories) ? options.directories : [options.directories ?? defaultDirectories[0]]).map(normalizeDir);
108
200
  const directives = {
109
- visible: { ...defaults.directives.visible, ...options.directives?.visible },
110
- idle: { ...defaults.directives.idle, ...options.directives?.idle },
111
- media: { ...defaults.directives.media, ...options.directives?.media },
112
- defer: { ...defaults.directives.defer, ...options.directives?.defer },
113
- interaction: { ...defaults.directives.interaction, ...options.directives?.interaction }
201
+ visible: { ...DEFAULT_DIRECTIVES.visible, ...options.directives?.visible },
202
+ idle: { ...DEFAULT_DIRECTIVES.idle, ...options.directives?.idle },
203
+ media: { ...DEFAULT_DIRECTIVES.media, ...options.directives?.media },
204
+ defer: { ...DEFAULT_DIRECTIVES.defer, ...options.directives?.defer },
205
+ interaction: { ...DEFAULT_DIRECTIVES.interaction, ...options.directives?.interaction }
114
206
  };
115
207
  const clientDirectiveDefinitions = options.directives?.custom ?? [];
116
208
  validateOptions(options, directives);
@@ -121,37 +213,33 @@ function shopifyThemeIslands(options = {}) {
121
213
  let absDirs = rawDirs;
122
214
  const islandFiles = new Set;
123
215
  let scanned = false;
124
- const inDirectory = (file) => absDirs.some((dir) => file.startsWith(dir));
125
216
  return {
126
217
  name: "vite-plugin-shopify-theme-islands",
127
218
  enforce: "pre",
128
219
  configResolved(config) {
129
220
  root = config.root;
130
221
  resolvedDirs = resolveAliases(rawDirs, config);
131
- absDirs = resolvedDirs.map((d) => d.startsWith(root) ? d : join(root, d.replace(/^\//, "")));
222
+ absDirs = resolvedDirs.map((d) => d.startsWith(root) ? d : join2(root, d.replace(/^\//, "")));
132
223
  },
133
224
  buildStart() {
134
225
  if (scanned)
135
226
  return;
136
227
  scanned = true;
137
228
  const t0 = performance.now();
138
- scanForIslandFiles(root, islandFiles);
139
- const scanMs = (performance.now() - t0).toFixed(1);
140
- for (const f of islandFiles)
141
- if (inDirectory(f))
142
- islandFiles.delete(f);
229
+ const initial = discoverIslandFiles(root, absDirs);
230
+ islandFiles.clear();
231
+ initial.forEach((f) => islandFiles.add(f));
143
232
  if (debug) {
233
+ const scanMs = (performance.now() - t0).toFixed(1);
144
234
  log(`Scanned in ${scanMs}ms`);
145
235
  log("Scanning directories:", resolvedDirs.map((d) => d + "**/*.{ts,js}").join(", "));
146
- const dirNames = [];
147
- for (const dir of absDirs)
148
- collectTagNames(dir, dirNames);
236
+ const dirNames = absDirs.flatMap((dir) => collectTagNames(dir));
149
237
  if (dirNames.length)
150
238
  log(`Found ${dirNames.length} directory island(s): [${dirNames.join(", ")}]`);
151
239
  if (islandFiles.size) {
152
240
  log(`Found ${islandFiles.size} island file(s) via mixin import:`);
153
241
  for (const f of islandFiles)
154
- log(" ", relative(root, f));
242
+ log(" ", relative2(root, f));
155
243
  }
156
244
  log("Directives:", directives);
157
245
  }
@@ -159,12 +247,12 @@ function shopifyThemeIslands(options = {}) {
159
247
  transform(code, id) {
160
248
  if (!TS_JS_RE.test(id))
161
249
  return;
162
- if (code.includes("shopify-theme-islands/island") && ISLAND_IMPORT_RE.test(code) && !inDirectory(id)) {
250
+ if (code.includes("shopify-theme-islands/island") && ISLAND_IMPORT_RE.test(code) && !inDirectory(id, absDirs)) {
163
251
  islandFiles.add(id);
164
- log("Detected island:", relative(root, id));
252
+ log("Detected island:", relative2(root, id));
165
253
  } else {
166
254
  if (islandFiles.delete(id))
167
- log("Removed island:", relative(root, id));
255
+ log("Removed island:", relative2(root, id));
168
256
  }
169
257
  },
170
258
  watchChange(id, { event }) {
@@ -172,16 +260,16 @@ function shopifyThemeIslands(options = {}) {
172
260
  return;
173
261
  if (event === "delete") {
174
262
  if (islandFiles.delete(id))
175
- log("Removed island (deleted):", relative(root, id));
263
+ log("Removed island (deleted):", relative2(root, id));
176
264
  } else {
177
265
  try {
178
- const content = readFileSync(id, "utf-8");
179
- if (ISLAND_IMPORT_RE.test(content) && !inDirectory(id)) {
266
+ const content = readFileSync2(id, "utf-8");
267
+ if (ISLAND_IMPORT_RE.test(content) && !inDirectory(id, absDirs)) {
180
268
  islandFiles.add(id);
181
- log("Detected island (watchChange):", relative(root, id));
269
+ log("Detected island (watchChange):", relative2(root, id));
182
270
  } else {
183
271
  if (islandFiles.delete(id))
184
- log("Removed island (watchChange):", relative(root, id));
272
+ log("Removed island (watchChange):", relative2(root, id));
185
273
  }
186
274
  } catch {}
187
275
  }
@@ -195,38 +283,23 @@ function shopifyThemeIslands(options = {}) {
195
283
  async load(id) {
196
284
  if (id !== RESOLVED_ID)
197
285
  return;
198
- const globs = resolvedDirs.map((dir) => `...import.meta.glob(${JSON.stringify(dir + "**/*.{ts,js}")})`);
199
- const islandPaths = islandFiles.size ? [...islandFiles].map((file) => "/" + relative(root, file).replace(/\\/g, "/")) : null;
200
- const islandsEntries = [`{ ${globs.join(", ")} }`];
201
- if (islandPaths)
202
- islandsEntries.push(`import.meta.glob(${JSON.stringify(islandPaths)})`);
203
- const directiveImports = [];
204
- const mapEntries = [];
205
- for (const [i, def] of clientDirectiveDefinitions.entries()) {
286
+ const directoryGlobs = resolvedDirs.map((dir) => dir + "**/*.{ts,js}");
287
+ const islandPaths = islandFiles.size > 0 ? getIslandPathsForLoad(islandFiles, root) : null;
288
+ const customDirectives = [];
289
+ for (const def of clientDirectiveDefinitions) {
206
290
  const resolved = await this.resolve(def.entrypoint);
207
291
  if (!resolved) {
208
292
  throw new Error(`[vite-plugin-shopify-theme-islands] Cannot resolve custom directive entrypoint: "${def.entrypoint}"`);
209
293
  }
210
- directiveImports.push(`import _directive${i} from ${JSON.stringify(resolved.id)};`);
211
- mapEntries.push(` [${JSON.stringify(def.name)}, _directive${i}]`);
294
+ customDirectives.push({ name: def.name, entrypoint: resolved.id });
212
295
  }
213
- const lines = [
214
- ...directiveImports,
215
- `import { revive as _islands } from ${JSON.stringify(runtimePath)};`,
216
- `const islands = Object.assign({}, ${islandsEntries.join(", ")});`,
217
- `const options = ${JSON.stringify({ directives, debug, retry: options.retry })};`
218
- ];
219
- if (mapEntries.length) {
220
- lines.push(`const customDirectives = new Map([
221
- ${mapEntries.join(`,
222
- `)}
223
- ]);`);
224
- lines.push(`export const { disconnect } = _islands(islands, options, customDirectives);`);
225
- } else {
226
- lines.push(`export const { disconnect } = _islands(islands, options);`);
227
- }
228
- return lines.join(`
229
- `);
296
+ return buildReviveModuleSource({
297
+ runtimePath,
298
+ directoryGlobs,
299
+ islandPaths,
300
+ customDirectives,
301
+ reviveOptions: { directives, debug, retry: options.retry }
302
+ });
230
303
  }
231
304
  };
232
305
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Virtual revive module source generator.
3
+ * Single place for "what the client receives" when loading the revive virtual module.
4
+ */
5
+ import type { ReviveOptions } from "./contract.js";
6
+ export interface BuildReviveModuleSourceParams {
7
+ /** Resolved path to the runtime module (revive export). */
8
+ runtimePath: string;
9
+ /** import.meta.glob expressions for configured island directories. */
10
+ directoryGlobs: string[];
11
+ /** Additional discovered island paths outside the configured directories. */
12
+ islandPaths?: string[] | null;
13
+ /** Resolved custom directive modules keyed by attribute name. */
14
+ customDirectives?: Array<{
15
+ name: string;
16
+ entrypoint: string;
17
+ }>;
18
+ /** Options object passed to revive (JSON-serialized in output). */
19
+ reviveOptions: ReviveOptions;
20
+ }
21
+ /**
22
+ * Builds the source code for the virtual revive module.
23
+ * Used by the plugin's load() so the emitted shape is defined and testable in one place.
24
+ */
25
+ export declare function buildReviveModuleSource(params: BuildReviveModuleSourceParams): string;
package/dist/runtime.d.ts CHANGED
@@ -13,7 +13,11 @@
13
13
  * Directives can be combined; all conditions must be met before loading.
14
14
  * A MutationObserver re-runs the same logic for elements added dynamically.
15
15
  */
16
- import type { ClientDirective, ReviveOptions } from "./index.js";
17
- export declare function revive(islands: Record<string, () => Promise<unknown>>, options?: ReviveOptions, customDirectives?: Map<string, ClientDirective>): {
16
+ import { type ClientDirective, type IslandLoader, type ReviveOptions, type RevivePayload } from "./contract.js";
17
+ export declare function revive(payload: RevivePayload): {
18
+ disconnect: () => void;
19
+ };
20
+ /** @deprecated Pass a RevivePayload object instead. Will be removed in v2.0. */
21
+ export declare function revive(islands: Record<string, IslandLoader>, options?: ReviveOptions, customDirectives?: Map<string, ClientDirective>): {
18
22
  disconnect: () => void;
19
23
  };
package/dist/runtime.js CHANGED
@@ -1,3 +1,52 @@
1
+ // src/contract.ts
2
+ var DEFAULT_DIRECTIVES = {
3
+ visible: { attribute: "client:visible", rootMargin: "200px", threshold: 0 },
4
+ idle: { attribute: "client:idle", timeout: 500 },
5
+ media: { attribute: "client:media" },
6
+ defer: { attribute: "client:defer", delay: 3000 },
7
+ interaction: {
8
+ attribute: "client:interaction",
9
+ events: ["mouseenter", "touchstart", "focusin"]
10
+ }
11
+ };
12
+ var DEFAULT_RETRY = { retries: 0, delay: 1000 };
13
+ function normalizeReviveOptions(options) {
14
+ const d = DEFAULT_DIRECTIVES;
15
+ const r = DEFAULT_RETRY;
16
+ const dir = options?.directives;
17
+ return {
18
+ directives: {
19
+ visible: { ...d.visible, ...dir?.visible },
20
+ idle: { ...d.idle, ...dir?.idle },
21
+ media: { ...d.media, ...dir?.media },
22
+ defer: { ...d.defer, ...dir?.defer },
23
+ interaction: { ...d.interaction, ...dir?.interaction }
24
+ },
25
+ debug: options?.debug ?? false,
26
+ retry: { ...r, ...options?.retry }
27
+ };
28
+ }
29
+ var basename = (key) => key.split("/").pop() ?? key;
30
+ function defaultKeyToTag(key) {
31
+ const filename = basename(key);
32
+ const tag = filename.replace(/\.(ts|js)$/, "");
33
+ const skip = !tag.includes("-");
34
+ if (skip && tag)
35
+ console.warn(`[islands] Skipping "${filename}" — filename must contain a hyphen to match a valid custom element tag (e.g. rename to "${tag}-island.ts")`);
36
+ return { tag, skip };
37
+ }
38
+ function buildIslandMap(payload) {
39
+ const map = new Map;
40
+ for (const [key, loader] of Object.entries(payload.islands)) {
41
+ const { tag, skip } = defaultKeyToTag(key);
42
+ if (skip)
43
+ continue;
44
+ if (!map.has(tag))
45
+ map.set(tag, loader);
46
+ }
47
+ return map;
48
+ }
49
+
1
50
  // src/runtime.ts
2
51
  var dispatch = (name, detail) => document.dispatchEvent(new CustomEvent(name, { detail }));
3
52
  function media(query) {
@@ -56,35 +105,27 @@ function idle(timeout) {
56
105
  });
57
106
  }
58
107
  var noop = (..._) => {};
59
- function revive(islands, options, customDirectives) {
60
- const attrVisible = options?.directives?.visible?.attribute ?? "client:visible";
61
- const attrMedia = options?.directives?.media?.attribute ?? "client:media";
62
- const attrIdle = options?.directives?.idle?.attribute ?? "client:idle";
63
- const attrDefer = options?.directives?.defer?.attribute ?? "client:defer";
64
- const attrInteraction = options?.directives?.interaction?.attribute ?? "client:interaction";
65
- const interactionEvents = options?.directives?.interaction?.events ?? [
66
- "mouseenter",
67
- "touchstart",
68
- "focusin"
69
- ];
70
- const rootMargin = options?.directives?.visible?.rootMargin ?? "200px";
71
- const threshold = options?.directives?.visible?.threshold ?? 0;
72
- const idleTimeout = options?.directives?.idle?.timeout ?? 500;
73
- const deferDelay = options?.directives?.defer?.delay ?? 3000;
74
- const debug = options?.debug ?? false;
75
- const retries = options?.retry?.retries ?? 0;
76
- const retryDelay = options?.retry?.delay ?? 1000;
77
- const islandMap = new Map;
78
- for (const [key, loader] of Object.entries(islands)) {
79
- const filename = key.split("/").pop();
80
- const tagName = filename.replace(/\.(ts|js)$/, "");
81
- if (!tagName.includes("-")) {
82
- console.warn(`[islands] Skipping "${filename}" — filename must contain a hyphen to match a valid custom element tag name (e.g. rename to "${tagName}-island.ts")`);
83
- continue;
84
- }
85
- if (!islandMap.has(tagName))
86
- islandMap.set(tagName, loader);
87
- }
108
+ function isRevivePayload(v) {
109
+ return typeof v === "object" && v !== null && "islands" in v && !Array.isArray(v);
110
+ }
111
+ function revive(islandsOrPayload, options, customDirectives) {
112
+ const payload = isRevivePayload(islandsOrPayload) ? islandsOrPayload : { islands: islandsOrPayload, options, customDirectives };
113
+ const opts = normalizeReviveOptions(payload.options);
114
+ const islandMap = buildIslandMap(payload);
115
+ const resolvedDirectives = payload.customDirectives;
116
+ const attrVisible = opts.directives.visible.attribute;
117
+ const attrMedia = opts.directives.media.attribute;
118
+ const attrIdle = opts.directives.idle.attribute;
119
+ const attrDefer = opts.directives.defer.attribute;
120
+ const attrInteraction = opts.directives.interaction.attribute;
121
+ const interactionEvents = opts.directives.interaction.events;
122
+ const rootMargin = opts.directives.visible.rootMargin;
123
+ const threshold = opts.directives.visible.threshold;
124
+ const idleTimeout = opts.directives.idle.timeout;
125
+ const deferDelay = opts.directives.defer.delay;
126
+ const debug = opts.debug;
127
+ const retries = opts.retry.retries;
128
+ const retryDelay = opts.retry.delay;
88
129
  const queued = new Set;
89
130
  let initDone = false;
90
131
  const loaded = new Set;
@@ -116,8 +157,8 @@ function revive(islands, options, customDirectives) {
116
157
  pushAttr(attrIdle, el.getAttribute(attrIdle));
117
158
  pushAttr(attrDefer, el.getAttribute(attrDefer));
118
159
  pushAttr(attrInteraction, el.getAttribute(attrInteraction));
119
- if (customDirectives?.size) {
120
- for (const a of customDirectives.keys()) {
160
+ if (resolvedDirectives?.size) {
161
+ for (const a of resolvedDirectives.keys()) {
121
162
  if (el.hasAttribute(a))
122
163
  parts.push(a);
123
164
  }
@@ -218,9 +259,9 @@ function revive(islands, options, customDirectives) {
218
259
  retryCount.delete(tagName);
219
260
  queued.delete(tagName);
220
261
  };
221
- if (customDirectives?.size) {
262
+ if (resolvedDirectives?.size) {
222
263
  const matched = [];
223
- for (const [attrName, directiveFn] of customDirectives) {
264
+ for (const [attrName, directiveFn] of resolvedDirectives) {
224
265
  const value = el.getAttribute(attrName);
225
266
  if (value !== null)
226
267
  matched.push([attrName, directiveFn, value]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-shopify-theme-islands",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Vite plugin for island architecture in Shopify themes",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.10",
@@ -8,8 +8,9 @@ description: >
8
8
  Custom directives run after all built-in conditions resolve.
9
9
  type: core
10
10
  library: vite-plugin-shopify-theme-islands
11
- library_version: "1.1.0"
11
+ library_version: "1.1.1"
12
12
  sources:
13
+ - Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
13
14
  - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
14
15
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
15
16
  ---
@@ -96,7 +97,7 @@ const networkDirective: ClientDirective = async (load, _opts, el) => {
96
97
  };
97
98
  ```
98
99
 
99
- The directive function can be async. Unhandled rejections fire `islands:error` on the element.
100
+ The directive function can be async. Unhandled rejections fire the document-level `islands:error` event, so `onIslandError()` observers still see directive failures.
100
101
 
101
102
  ### AND-latch with multiple matching directives
102
103
 
@@ -180,6 +181,38 @@ With two matching custom directives, `remaining = 2`. Each `load()` call decreme
180
181
 
181
182
  Source: src/runtime.ts — `let remaining = matched.length`
182
183
 
184
+ ### HIGH Duplicate custom directive names or collisions with built-ins fail plugin setup
185
+
186
+ Wrong:
187
+
188
+ ```ts
189
+ shopifyThemeIslands({
190
+ directives: {
191
+ visible: { attribute: "data:visible" },
192
+ custom: [
193
+ { name: "client:hash", entrypoint: "./src/directives/hash.ts" },
194
+ { name: "data:visible", entrypoint: "./src/directives/other.ts" },
195
+ { name: "client:hash", entrypoint: "./src/directives/duplicate.ts" },
196
+ ],
197
+ },
198
+ });
199
+ ```
200
+
201
+ Correct:
202
+
203
+ ```ts
204
+ shopifyThemeIslands({
205
+ directives: {
206
+ visible: { attribute: "data:visible" },
207
+ custom: [{ name: "client:hash", entrypoint: "./src/directives/hash.ts" }],
208
+ },
209
+ });
210
+ ```
211
+
212
+ Custom directive names must be unique and must not collide with any built-in directive name, including renamed built-ins.
213
+
214
+ Source: src/index.ts — validateOptions duplicate and built-in conflict checks
215
+
183
216
  ### HIGH Entrypoint path missing `./` prefix
184
217
 
185
218
  Wrong:
@@ -200,7 +233,7 @@ Correct:
200
233
  }
201
234
  ```
202
235
 
203
- Vite's resolver may fail to locate the file without the `./` relative prefix. The plugin throws a build error if the entrypoint cannot be resolved.
236
+ Custom directive entrypoints are resolved through Vite. Relative local files should usually use `./...`; unresolved entrypoints fail the build.
204
237
 
205
238
  Source: src/index.ts — `this.resolve(def.entrypoint)` throws on null
206
239
 
@@ -5,10 +5,11 @@ description: >
5
5
  client:media (matchMedia query), client:idle (requestIdleCallback),
6
6
  client:defer (setTimeout delay), client:interaction (mouseenter/touchstart/focusin).
7
7
  Directives resolve sequentially — visible → media → idle → defer → interaction → custom.
8
- Per-element value overrides. Empty client:media warning.
8
+ Per-element value overrides. Empty client:media warning. Whitespace-only
9
+ client:interaction values warn and fall back to default events.
9
10
  type: core
10
11
  library: vite-plugin-shopify-theme-islands
11
- library_version: "1.1.0"
12
+ library_version: "1.1.1"
12
13
  sources:
13
14
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
14
15
  - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
@@ -96,7 +97,9 @@ An empty `client:defer` attribute is NOT zero — it falls back to the configure
96
97
  <cart-flyout client:interaction="mouseenter"></cart-flyout>
97
98
  ```
98
99
 
99
- An empty `client:interaction` attribute silently uses the configured default events no warning is emitted (unlike `client:media`).
100
+ An empty `client:interaction` attribute uses the configured default events with no warning. A whitespace-only value such as `client:interaction=" "` emits a warning and still falls back to the default events.
101
+
102
+ Source: src/runtime.ts — interaction token parsing and fallback warning
100
103
 
101
104
  ### Changing built-in directive defaults globally
102
105
 
@@ -131,6 +134,28 @@ An empty `client:media` value emits a console warning and skips the media check
131
134
 
132
135
  Source: src/runtime.ts — `if (query === "")` branch
133
136
 
137
+ ### MEDIUM Whitespace-only `client:interaction` value warns and falls back
138
+
139
+ Wrong:
140
+
141
+ ```html
142
+ <cart-flyout client:interaction=" "></cart-flyout>
143
+ ```
144
+
145
+ Correct:
146
+
147
+ ```html
148
+ <!-- Either omit the value entirely for defaults... -->
149
+ <cart-flyout client:interaction></cart-flyout>
150
+
151
+ <!-- ...or provide explicit event names -->
152
+ <cart-flyout client:interaction="mouseenter focusin"></cart-flyout>
153
+ ```
154
+
155
+ Whitespace-only values are not treated the same as an empty attribute. The runtime warns and falls back to the configured default events.
156
+
157
+ Source: src/runtime.ts — `interactionAttr.split(/\s+/).filter(Boolean)`
158
+
134
159
  ### HIGH Multiple directives are AND, not OR
135
160
 
136
161
  Wrong assumption:
@@ -10,10 +10,10 @@ description: >
10
10
  navigation teardown.
11
11
  type: core
12
12
  library: vite-plugin-shopify-theme-islands
13
- library_version: "1.1.0"
13
+ library_version: "1.1.1"
14
14
  sources:
15
15
  - Rees1993/vite-plugin-shopify-theme-islands:src/events.ts
16
- - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
16
+ - Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
17
17
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
18
18
  ---
19
19
 
@@ -97,7 +97,7 @@ document.addEventListener("islands:load", (e) => {
97
97
  });
98
98
  ```
99
99
 
100
- The `DocumentEventMap` augmentation is declared in the main package's `index.ts`. It is only in scope when the import is present in the same tsconfig compilation.
100
+ The `DocumentEventMap` augmentation is declared in `contract.ts` and re-exported via the main package entry. It is only in scope when the import is present in the same tsconfig compilation.
101
101
 
102
102
  ## Common Mistakes
103
103
 
@@ -9,9 +9,10 @@ description: >
9
9
  directories, or enabling retry.
10
10
  type: core
11
11
  library: vite-plugin-shopify-theme-islands
12
- library_version: "1.1.0"
12
+ library_version: "1.1.1"
13
13
  sources:
14
14
  - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
15
+ - Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
15
16
  ---
16
17
 
17
18
  ## Setup
@@ -195,7 +196,7 @@ shopifyThemeIslands({
195
196
 
196
197
  `directives` accepts only `visible`, `idle`, `media`, `defer`, `interaction`, and `custom`. `retry` at `directives.retry` is silently ignored.
197
198
 
198
- Source: src/index.ts:ShopifyThemeIslandsOptions
199
+ Source: src/index.tsShopifyThemeIslandsOptions
199
200
 
200
201
  ### HIGH Wrong key name for retry count
201
202
 
@@ -214,4 +215,4 @@ shopifyThemeIslands({ retry: { retries: 3 } });
214
215
 
215
216
  Unknown keys are silently ignored. The correct field is `retries`.
216
217
 
217
- Source: src/index.ts:RetryConfig
218
+ Source: src/contract.tsRetryConfig
@@ -8,9 +8,11 @@ description: >
8
8
  base class, and child island cascade behaviour.
9
9
  type: core
10
10
  library: vite-plugin-shopify-theme-islands
11
- library_version: "1.1.0"
11
+ library_version: "1.1.1"
12
12
  sources:
13
13
  - Rees1993/vite-plugin-shopify-theme-islands:src/island.ts
14
+ - Rees1993/vite-plugin-shopify-theme-islands:src/discovery.ts
15
+ - Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
14
16
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
15
17
  ---
16
18
 
@@ -119,7 +121,7 @@ customElements.define("search-bar", SearchBar);
119
121
 
120
122
  Without the `Island` import the plugin cannot detect the file. The element appears in the DOM but the module is never lazy-loaded.
121
123
 
122
- Source: src/index.ts — ISLAND_IMPORT_RE, scanForIslandFiles
124
+ Source: src/discovery.ts — ISLAND_IMPORT_RE, discoverIslandFiles
123
125
 
124
126
  ### HIGH Missing `customElements.define` call
125
127
 
@@ -148,6 +150,31 @@ The plugin loads the module but the custom element never upgrades without `custo
148
150
 
149
151
  Source: src/runtime.ts — loader() is called but registration is the file's responsibility
150
152
 
153
+ ### HIGH Filename without a hyphen is skipped as an invalid custom element tag
154
+
155
+ Wrong:
156
+
157
+ ```ts
158
+ // frontend/js/islands/cartdrawer.ts
159
+ class CartDrawer extends HTMLElement {}
160
+ customElements.define("cartdrawer", CartDrawer);
161
+ ```
162
+
163
+ Correct:
164
+
165
+ ```ts
166
+ // frontend/js/islands/cart-drawer.ts
167
+ class CartDrawer extends HTMLElement {}
168
+
169
+ if (!customElements.get("cart-drawer")) {
170
+ customElements.define("cart-drawer", CartDrawer);
171
+ }
172
+ ```
173
+
174
+ The runtime derives the tag name from the filename and skips non-hyphenated names with a warning. Use valid custom element tag names in filenames.
175
+
176
+ Source: src/contract.ts — defaultKeyToTag()
177
+
151
178
  ### MEDIUM Child island activates before parent is ready
152
179
 
153
180
  Wrong assumption: