vite-plugin-shopify-theme-islands 1.0.2 → 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.
package/README.md CHANGED
@@ -190,16 +190,51 @@ Loads the island after a fixed delay. The delay in milliseconds is read from the
190
190
 
191
191
  Unlike `client:idle`, which waits for genuine browser idle time, `client:defer` always waits exactly the specified number of milliseconds.
192
192
 
193
+ ### `client:interaction`
194
+
195
+ Loads the island when the user interacts with the element. Listens for `mouseenter`, `touchstart`, and `focusin` by default — the module starts downloading the moment the user moves their cursor toward or focuses the element.
196
+
197
+ ```html
198
+ <cart-flyout client:interaction>
199
+ <!-- ... -->
200
+ </cart-flyout>
201
+ ```
202
+
203
+ The attribute value overrides the events for that element only (space-separated MDN event names):
204
+
205
+ ```html
206
+ <!-- only mouseenter — touchstart and focusin are excluded -->
207
+ <cart-flyout client:interaction="mouseenter">
208
+ <!-- ... -->
209
+ </cart-flyout>
210
+ ```
211
+
212
+ Combine with `client:visible` to avoid attaching listeners to off-screen elements. Because directives resolve sequentially, interaction listeners are only registered once the element has entered the viewport:
213
+
214
+ ```html
215
+ <mega-menu client:visible client:interaction>
216
+ <!-- loads when visible, then waits for hover/touch/focus -->
217
+ </mega-menu>
218
+ ```
219
+
193
220
  ### Combining directives
194
221
 
195
- Directives can be combined — the element waits for all conditions to be met before loading:
222
+ Directives can be combined — the element works through each condition in sequence before loading. The resolution order is: `visible` → `media` → `idle` → `defer` → `interaction` → custom directives.
196
223
 
197
224
  ```html
225
+ <!-- must scroll into view, then wait for user interaction -->
226
+ <product-recommendations client:visible client:interaction>
227
+ <!-- ... -->
228
+ </product-recommendations>
229
+
230
+ <!-- must scroll into view, then wait for idle time -->
198
231
  <heavy-widget client:visible client:idle>
199
232
  <!-- ... -->
200
233
  </heavy-widget>
201
234
  ```
202
235
 
236
+ Because conditions resolve sequentially, each directive is only evaluated after the previous one has passed. Interaction listeners, for example, are never attached to an element that isn't yet visible.
237
+
203
238
  ### Custom directives
204
239
 
205
240
  Register your own loading conditions via `directives.custom`. A custom directive is a function that receives a `load` callback and decides when to call it.
@@ -207,24 +242,28 @@ Register your own loading conditions via `directives.custom`. A custom directive
207
242
  #### 1. Write the directive
208
243
 
209
244
  ```ts
210
- // src/directives/hover.ts
245
+ // src/directives/hash.ts
211
246
  import type { ClientDirective } from "vite-plugin-shopify-theme-islands";
212
247
 
213
- const hoverDirective: ClientDirective = (load, _opts, el) => {
214
- el.addEventListener("mouseenter", load, { once: true });
248
+ const hashDirective: ClientDirective = (load, opts) => {
249
+ const target = opts.value;
250
+ if (location.hash === target) { load(); return; }
251
+ window.addEventListener("hashchange", () => {
252
+ if (location.hash === target) load();
253
+ });
215
254
  };
216
255
 
217
- export default hoverDirective;
256
+ export default hashDirective;
218
257
  ```
219
258
 
220
- `mouseenter` fires before `click`, so the module starts downloading the moment the user moves their cursor toward the element by the time they click it's already loaded.
259
+ Useful for anchor-linked sections `<product-reviews client:hash="#reviews">` loads only when the URL fragment matches, so deep-links like `/products/shirt#reviews` activate the island immediately while other visitors never load it.
221
260
 
222
261
  The function signature is `(load, options, el) => void | Promise<void>`:
223
262
 
224
263
  | Parameter | Type | Description |
225
264
  | --------------- | ---------------------- | ----------------------------------------------------- |
226
265
  | `load` | `() => Promise<void>` | Call this to trigger the island module load |
227
- | `options.name` | `string` | The matched attribute name, e.g. `'client:hover'` |
266
+ | `options.name` | `string` | The matched attribute name, e.g. `'client:hash'` |
228
267
  | `options.value` | `string` | The attribute value; empty string if no value was set |
229
268
  | `el` | `HTMLElement` | The island element |
230
269
 
@@ -238,7 +277,7 @@ export default defineConfig({
238
277
  plugins: [
239
278
  shopifyThemeIslands({
240
279
  directives: {
241
- custom: [{ name: "client:hover", entrypoint: "./src/directives/hover.ts" }],
280
+ custom: [{ name: "client:hash", entrypoint: "./src/directives/hash.ts" }],
242
281
  },
243
282
  }),
244
283
  ],
@@ -250,9 +289,9 @@ The `entrypoint` supports Vite aliases.
250
289
  #### 3. Use it in Liquid
251
290
 
252
291
  ```html
253
- <quick-add client:hover>
292
+ <product-reviews client:hash="#reviews">
254
293
  <!-- ... -->
255
- </quick-add>
294
+ </product-reviews>
256
295
  ```
257
296
 
258
297
  #### Ordering
@@ -260,21 +299,21 @@ The `entrypoint` supports Vite aliases.
260
299
  Built-in directives always run first. A custom directive is only invoked after all built-in conditions on the element have been met. This means you can gate a custom directive behind `client:visible` to avoid wiring event listeners for off-screen elements:
261
300
 
262
301
  ```html
263
- <!-- element must enter the viewport before the hover handler is registered -->
264
- <quick-add client:visible client:hover>
302
+ <!-- element must enter the viewport before the hash handler is registered -->
303
+ <product-reviews client:visible client:hash="#reviews">
265
304
  <!-- ... -->
266
- </quick-add>
305
+ </product-reviews>
267
306
  ```
268
307
 
269
308
  The custom directive owns the `load()` call — the built-in chain never calls it directly when a custom directive is matched.
270
309
 
271
- Multiple custom directives on the same element use AND semantics — the island loads only once all matched directives have called `load()`. For example, given two registered custom directives `client:hover` and `client:focus`:
310
+ Multiple custom directives on the same element use AND semantics — the island loads only once all matched directives have called `load()`. For example, given two registered custom directives `client:hash` and `client:network`:
272
311
 
273
312
  ```html
274
- <!-- client:visible runs first (built-in); then both client:hover and client:focus must fire -->
275
- <quick-add client:visible client:hover client:focus>
313
+ <!-- client:visible runs first (built-in); then both client:hash and client:network must fire -->
314
+ <product-reviews client:visible client:hash="#reviews" client:network="4g">
276
315
  <!-- ... -->
277
- </quick-add>
316
+ </product-reviews>
278
317
  ```
279
318
 
280
319
  ## Configuration
@@ -307,6 +346,10 @@ shopifyThemeIslands({
307
346
  attribute: "client:defer", // HTML attribute name
308
347
  delay: 3000, // fallback delay (ms) when the attribute has no value
309
348
  },
349
+ interaction: {
350
+ attribute: "client:interaction", // HTML attribute name
351
+ events: ["mouseenter", "touchstart", "focusin"], // DOM events that trigger load
352
+ },
310
353
  custom: [], // custom directives — see Custom directives above
311
354
  },
312
355
  });
@@ -372,12 +415,12 @@ The `/events` entry point provides typed helpers that unwrap `e.detail` for you
372
415
  ```ts
373
416
  import { onIslandLoad, onIslandError } from "vite-plugin-shopify-theme-islands/events";
374
417
 
375
- const offLoad = onIslandLoad(({ tag }) => {
376
- analytics.track("island_loaded", { tag });
418
+ const offLoad = onIslandLoad(({ tag, duration, attempt }) => {
419
+ analytics.track("island_loaded", { tag, duration, attempt });
377
420
  });
378
421
 
379
- const offError = onIslandError(({ tag, error }) => {
380
- errorReporter.capture(error, { context: tag });
422
+ const offError = onIslandError(({ tag, error, attempt }) => {
423
+ errorReporter.capture(error, { context: tag, attempt });
381
424
  });
382
425
 
383
426
  // Remove listeners when no longer needed (e.g. SPA teardown)
@@ -395,10 +438,10 @@ document.addEventListener("islands:load", (e) => {
395
438
  });
396
439
  ```
397
440
 
398
- | Event | Detail properties | When it fires |
399
- | --------------- | ----------------- | ---------------------------------------------------------- |
400
- | `islands:load` | `tag` | Island module resolves successfully |
401
- | `islands:error` | `tag`, `error` | Load or custom directive fails (alongside `console.error`) |
441
+ | Event | Detail properties | When it fires |
442
+ | --------------- | ------------------------------ | ---------------------------------------------------------- |
443
+ | `islands:load` | `tag`, `duration`, `attempt` | Island module resolves successfully |
444
+ | `islands:error` | `tag`, `error`, `attempt` | Load or custom directive fails (alongside `console.error`) |
402
445
 
403
446
  `islands:error` fires on each retry attempt, not just the final failure. Multiple independent listeners are supported — each receives its own event.
404
447
 
@@ -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,41 +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/hover.ts
20
- * import type { ClientDirective } from 'vite-plugin-shopify-theme-islands';
21
- *
22
- * const hoverDirective: ClientDirective = (load, _opts, el) => {
23
- * el.addEventListener('mouseenter', load, { once: true });
24
- * };
25
- *
26
- * export default hoverDirective;
27
- * ```
28
- *
29
- * Register it in `vite.config.ts`:
30
- * ```ts
31
- * shopifyThemeIslands({
32
- * directives: {
33
- * custom: [{ name: 'client:hover', entrypoint: './src/directives/hover.ts' }],
34
- * },
35
- * })
36
- * ```
37
- */
38
- export type ClientDirective = (load: ClientDirectiveLoader, options: ClientDirectiveOptions, el: HTMLElement) => void | Promise<void>;
4
+ export type { ClientDirective, ClientDirectiveOptions } from "./contract.js";
39
5
  /** Plugin option entry for registering a custom client directive. */
40
6
  export interface ClientDirectiveDefinition {
41
7
  /** HTML attribute name, e.g. `'client:on-click'` */
@@ -73,38 +39,19 @@ export interface DirectivesConfig {
73
39
  /** Fallback delay (ms) when the attribute has no value. Default: `3000` */
74
40
  delay?: number;
75
41
  };
42
+ /** Configuration for the `client:interaction` directive (mouseenter/touchstart/focusin). */
43
+ interaction?: {
44
+ /** HTML attribute name. Default: `'client:interaction'` */
45
+ attribute?: string;
46
+ /** DOM event names to listen for. Default: `['mouseenter', 'touchstart', 'focusin']` */
47
+ events?: string[];
48
+ };
76
49
  /** Custom client directives to register. Each entry maps an attribute name to a module entrypoint. */
77
50
  custom?: ClientDirectiveDefinition[];
78
51
  }
79
- /** Runtime-facing directive configuration omits plugin-only `custom` directives. */
80
- export type RuntimeDirectivesConfig = Omit<DirectivesConfig, "custom">;
81
- /** Retry configuration for failed island loads. */
82
- export interface RetryConfig {
83
- /** Number of times to retry after the initial failure. Default: `0` (no auto-retry) */
84
- retries?: number;
85
- /** Base delay in ms between retries; doubles each attempt. Default: `1000` */
86
- delay?: number;
87
- }
88
- /** Event detail for the `islands:load` DOM event. */
89
- export interface IslandLoadDetail {
90
- /** The custom element tag name, e.g. `'product-form'` */
91
- tag: string;
92
- }
93
- /** Event detail for the `islands:error` DOM event. */
94
- export interface IslandErrorDetail {
95
- /** The custom element tag name, e.g. `'product-form'` */
96
- tag: string;
97
- /** The error thrown by the loader or custom directive */
98
- error: unknown;
99
- }
100
- declare global {
101
- interface DocumentEventMap {
102
- /** Fired after an island module resolves successfully. */
103
- "islands:load": CustomEvent<IslandLoadDetail>;
104
- /** Fired when an island load or custom directive fails. Fired on each retry attempt. */
105
- "islands:error": CustomEvent<IslandErrorDetail>;
106
- }
107
- }
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";
108
55
  export interface ShopifyThemeIslandsOptions {
109
56
  /** Directories to scan for island files. Accepts paths or Vite aliases. Default: `['/frontend/js/islands/']` */
110
57
  directories?: string | string[];
@@ -115,11 +62,4 @@ export interface ShopifyThemeIslandsOptions {
115
62
  /** Automatic retry behaviour for failed island loads. */
116
63
  retry?: RetryConfig;
117
64
  }
118
- export interface ReviveOptions {
119
- directives?: RuntimeDirectivesConfig;
120
- /** Log island activation and directive events to the console. Default: `false` */
121
- debug?: boolean;
122
- /** Automatic retry behaviour for failed island loads. */
123
- retry?: RetryConfig;
124
- }
125
65
  export default function shopifyThemeIslands(options?: ShopifyThemeIslandsOptions): Plugin;