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.
- package/dist/contract.d.ts +139 -0
- package/dist/discovery.d.ts +12 -0
- package/dist/index.d.ts +4 -81
- package/dist/index.js +170 -97
- package/dist/revive-module.d.ts +25 -0
- package/dist/runtime.d.ts +6 -2
- package/dist/runtime.js +74 -33
- package/package.json +1 -1
- package/skills/custom-directives/SKILL.md +36 -3
- package/skills/directives/SKILL.md +28 -3
- package/skills/lifecycle/SKILL.md +3 -3
- package/skills/setup/SKILL.md +4 -3
- package/skills/writing-islands/SKILL.md +29 -2
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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 ??
|
|
199
|
+
const rawDirs = (Array.isArray(options.directories) ? options.directories : [options.directories ?? defaultDirectories[0]]).map(normalizeDir);
|
|
108
200
|
const directives = {
|
|
109
|
-
visible: { ...
|
|
110
|
-
idle: { ...
|
|
111
|
-
media: { ...
|
|
112
|
-
defer: { ...
|
|
113
|
-
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 :
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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(" ",
|
|
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:",
|
|
252
|
+
log("Detected island:", relative2(root, id));
|
|
165
253
|
} else {
|
|
166
254
|
if (islandFiles.delete(id))
|
|
167
|
-
log("Removed island:",
|
|
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):",
|
|
263
|
+
log("Removed island (deleted):", relative2(root, id));
|
|
176
264
|
} else {
|
|
177
265
|
try {
|
|
178
|
-
const content =
|
|
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):",
|
|
269
|
+
log("Detected island (watchChange):", relative2(root, id));
|
|
182
270
|
} else {
|
|
183
271
|
if (islandFiles.delete(id))
|
|
184
|
-
log("Removed island (watchChange):",
|
|
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
|
|
199
|
-
const islandPaths = islandFiles.size
|
|
200
|
-
const
|
|
201
|
-
|
|
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
|
-
|
|
211
|
-
mapEntries.push(` [${JSON.stringify(def.name)}, _directive${i}]`);
|
|
294
|
+
customDirectives.push({ name: def.name, entrypoint: resolved.id });
|
|
212
295
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
17
|
-
export declare function revive(
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
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 (
|
|
120
|
-
for (const a of
|
|
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 (
|
|
262
|
+
if (resolvedDirectives?.size) {
|
|
222
263
|
const matched = [];
|
|
223
|
-
for (const [attrName, directiveFn] of
|
|
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
|
@@ -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.
|
|
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`
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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/
|
|
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
|
|
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
|
|
package/skills/setup/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
|
199
|
+
Source: src/index.ts — ShopifyThemeIslandsOptions
|
|
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/
|
|
218
|
+
Source: src/contract.ts — RetryConfig
|
|
@@ -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.
|
|
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/
|
|
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:
|