vite-plugin-shopify-theme-islands 0.6.1 → 0.7.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 +21 -0
- package/dist/index.js +16 -29
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +78 -33
- package/package.json +1 -1
- package/revive.d.ts +4 -1
package/README.md
CHANGED
|
@@ -36,6 +36,14 @@ import "vite-plugin-shopify-theme-islands/revive";
|
|
|
36
36
|
|
|
37
37
|
That's it. The plugin automatically scans your islands directory and wires everything up.
|
|
38
38
|
|
|
39
|
+
For SPA navigation teardown, import `disconnect` to stop the MutationObserver:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { disconnect } from "vite-plugin-shopify-theme-islands/revive";
|
|
43
|
+
// Call during SPA teardown:
|
|
44
|
+
disconnect();
|
|
45
|
+
```
|
|
46
|
+
|
|
39
47
|
## Writing islands
|
|
40
48
|
|
|
41
49
|
Two approaches — use either or both.
|
|
@@ -100,6 +108,17 @@ The plugin detects the mixin import at build time and includes the file as a laz
|
|
|
100
108
|
|
|
101
109
|
Both can be used together — directory scanning for new islands, the mixin for existing components you want to adopt without moving.
|
|
102
110
|
|
|
111
|
+
### Child island cascade
|
|
112
|
+
|
|
113
|
+
Child islands nested inside a parent island are automatically held until the parent's module has loaded. The runtime re-walks the parent's subtree on success, so child islands activate with their normal directives intact — no extra configuration needed.
|
|
114
|
+
|
|
115
|
+
```html
|
|
116
|
+
<product-form client:visible>
|
|
117
|
+
<!-- tab-switcher will not load until product-form has loaded -->
|
|
118
|
+
<tab-switcher client:idle></tab-switcher>
|
|
119
|
+
</product-form>
|
|
120
|
+
```
|
|
121
|
+
|
|
103
122
|
## Directives
|
|
104
123
|
|
|
105
124
|
Add these attributes to your custom elements in Liquid to control when the JavaScript loads. Without a directive, the island loads immediately.
|
|
@@ -133,6 +152,8 @@ Loads the island when a CSS media query matches.
|
|
|
133
152
|
</mobile-menu>
|
|
134
153
|
```
|
|
135
154
|
|
|
155
|
+
An empty attribute (`client:media=""`) logs a console warning and skips the media check — the island still loads.
|
|
156
|
+
|
|
136
157
|
### `client:idle`
|
|
137
158
|
|
|
138
159
|
Loads the island once the browser is idle (uses `requestIdleCallback` with a 500ms deadline, falls back to `setTimeout`).
|
package/dist/index.js
CHANGED
|
@@ -34,7 +34,7 @@ function resolveAliases(dirs, config) {
|
|
|
34
34
|
return dir;
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
|
-
function
|
|
37
|
+
function walkDir(dir, visitor) {
|
|
38
38
|
let entries;
|
|
39
39
|
try {
|
|
40
40
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
@@ -44,34 +44,23 @@ function collectTagNames(dir, names) {
|
|
|
44
44
|
for (const entry of entries) {
|
|
45
45
|
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
|
|
46
46
|
continue;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
const full = join(dir, entry.name);
|
|
48
|
+
if (entry.isDirectory())
|
|
49
|
+
walkDir(full, visitor);
|
|
50
|
+
else if (TS_JS_RE.test(entry.name))
|
|
51
|
+
visitor(entry.name, full);
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
function collectTagNames(dir, names) {
|
|
55
|
+
walkDir(dir, (name) => names.push(name.replace(TS_JS_RE, "")));
|
|
56
|
+
}
|
|
54
57
|
function scanForIslandFiles(dir, found) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
for (const entry of entries) {
|
|
62
|
-
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
|
|
63
|
-
continue;
|
|
64
|
-
const full = join(dir, entry.name);
|
|
65
|
-
if (entry.isDirectory()) {
|
|
66
|
-
scanForIslandFiles(full, found);
|
|
67
|
-
} else if (TS_JS_RE.test(entry.name)) {
|
|
68
|
-
try {
|
|
69
|
-
const content = readFileSync(full, "utf-8");
|
|
70
|
-
if (ISLAND_IMPORT_RE.test(content))
|
|
71
|
-
found.add(full);
|
|
72
|
-
} catch {}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
58
|
+
walkDir(dir, (_, full) => {
|
|
59
|
+
try {
|
|
60
|
+
if (ISLAND_IMPORT_RE.test(readFileSync(full, "utf-8")))
|
|
61
|
+
found.add(full);
|
|
62
|
+
} catch {}
|
|
63
|
+
});
|
|
75
64
|
}
|
|
76
65
|
function shopifyThemeIslands(options = {}) {
|
|
77
66
|
const rawDirs = (Array.isArray(options.directories) ? options.directories : [options.directories ?? defaults.directories[0]]).map(normalizeDir);
|
|
@@ -189,10 +178,8 @@ function shopifyThemeIslands(options = {}) {
|
|
|
189
178
|
${mapEntries.join(`,
|
|
190
179
|
`)}
|
|
191
180
|
]);`);
|
|
192
|
-
lines.push(`_islands(islands, options, customDirectives);`);
|
|
193
|
-
} else {
|
|
194
|
-
lines.push(`_islands(islands, options);`);
|
|
195
181
|
}
|
|
182
|
+
lines.push(`export const disconnect = _islands(islands, options${mapEntries.length ? ", customDirectives" : ""});`);
|
|
196
183
|
return lines.join(`
|
|
197
184
|
`);
|
|
198
185
|
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -13,4 +13,4 @@
|
|
|
13
13
|
* A MutationObserver re-runs the same logic for elements added dynamically.
|
|
14
14
|
*/
|
|
15
15
|
import type { ClientDirective, ReviveOptions } from "./index.js";
|
|
16
|
-
export declare function revive(islands: Record<string, () => Promise<unknown>>, options?: ReviveOptions, customDirectives?: Map<string, ClientDirective>): void;
|
|
16
|
+
export declare function revive(islands: Record<string, () => Promise<unknown>>, options?: ReviveOptions, customDirectives?: Map<string, ClientDirective>): () => void;
|
package/dist/runtime.js
CHANGED
|
@@ -10,14 +10,11 @@ function media(query) {
|
|
|
10
10
|
}
|
|
11
11
|
function visible(element, rootMargin, threshold, pending) {
|
|
12
12
|
return new Promise((resolve, reject) => {
|
|
13
|
-
const io = new IntersectionObserver((
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
resolve();
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
13
|
+
const io = new IntersectionObserver(([entry]) => {
|
|
14
|
+
if (entry.isIntersecting) {
|
|
15
|
+
io.disconnect();
|
|
16
|
+
pending.delete(element);
|
|
17
|
+
resolve();
|
|
21
18
|
}
|
|
22
19
|
}, { rootMargin, threshold });
|
|
23
20
|
io.observe(element);
|
|
@@ -38,9 +35,6 @@ function idle(timeout) {
|
|
|
38
35
|
setTimeout(resolve, timeout);
|
|
39
36
|
});
|
|
40
37
|
}
|
|
41
|
-
var customElementFilter = {
|
|
42
|
-
acceptNode: (node) => node.tagName.includes("-") ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
|
|
43
|
-
};
|
|
44
38
|
function revive(islands, options, customDirectives) {
|
|
45
39
|
const attrVisible = options?.directives?.visible?.attribute ?? "client:visible";
|
|
46
40
|
const attrMedia = options?.directives?.media?.attribute ?? "client:media";
|
|
@@ -51,52 +45,87 @@ function revive(islands, options, customDirectives) {
|
|
|
51
45
|
const idleTimeout = options?.directives?.idle?.timeout ?? 500;
|
|
52
46
|
const deferDelay = options?.directives?.defer?.delay ?? 3000;
|
|
53
47
|
const debug = options?.debug ?? false;
|
|
54
|
-
const log = debug ? (...args) => console.log("[islands]", ...args) : () => {};
|
|
55
48
|
const islandMap = new Map;
|
|
56
49
|
for (const [key, loader] of Object.entries(islands)) {
|
|
57
|
-
const
|
|
50
|
+
const filename = key.split("/").pop();
|
|
51
|
+
const tagName = filename.replace(/\.(ts|js)$/, "");
|
|
58
52
|
if (!tagName.includes("-")) {
|
|
59
|
-
console.warn(`[islands] Skipping "${
|
|
53
|
+
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")`);
|
|
60
54
|
continue;
|
|
61
55
|
}
|
|
62
56
|
if (!islandMap.has(tagName))
|
|
63
57
|
islandMap.set(tagName, loader);
|
|
64
58
|
}
|
|
65
|
-
log(`revive() ready — ${islandMap.size} island(s)`);
|
|
66
59
|
const queued = new Set;
|
|
60
|
+
let initDone = false;
|
|
61
|
+
const loaded = new Set;
|
|
67
62
|
const pendingVisible = new Map;
|
|
63
|
+
const isUnloadedIsland = (tag) => queued.has(tag) && !loaded.has(tag);
|
|
64
|
+
const customElementFilter = {
|
|
65
|
+
acceptNode: (node) => {
|
|
66
|
+
const tag = node.tagName;
|
|
67
|
+
if (!tag.includes("-"))
|
|
68
|
+
return NodeFilter.FILTER_SKIP;
|
|
69
|
+
const lowerTag = tag.toLowerCase();
|
|
70
|
+
if (isUnloadedIsland(lowerTag))
|
|
71
|
+
return NodeFilter.FILTER_REJECT;
|
|
72
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
68
75
|
async function loadIsland(tagName, el, loader) {
|
|
69
|
-
|
|
76
|
+
if (debug && !initDone) {
|
|
77
|
+
const parts = [];
|
|
78
|
+
const visibleVal = el.getAttribute(attrVisible);
|
|
79
|
+
if (visibleVal !== null)
|
|
80
|
+
parts.push(visibleVal ? `${attrVisible}="${visibleVal}"` : attrVisible);
|
|
81
|
+
const mediaVal = el.getAttribute(attrMedia);
|
|
82
|
+
if (mediaVal)
|
|
83
|
+
parts.push(`${attrMedia}="${mediaVal}"`);
|
|
84
|
+
const idleVal = el.getAttribute(attrIdle);
|
|
85
|
+
if (idleVal !== null)
|
|
86
|
+
parts.push(idleVal ? `${attrIdle}="${idleVal}"` : attrIdle);
|
|
87
|
+
const deferVal = el.getAttribute(attrDefer);
|
|
88
|
+
if (deferVal !== null)
|
|
89
|
+
parts.push(deferVal ? `${attrDefer}="${deferVal}"` : attrDefer);
|
|
90
|
+
if (customDirectives?.size) {
|
|
91
|
+
for (const a of customDirectives.keys()) {
|
|
92
|
+
if (el.hasAttribute(a))
|
|
93
|
+
parts.push(a);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (parts.length > 0)
|
|
97
|
+
console.log("[islands]", `<${tagName}> waiting · ${parts.join(", ")}`);
|
|
98
|
+
}
|
|
70
99
|
const msgs = [];
|
|
71
|
-
const note = debug ? (msg) => {
|
|
72
|
-
msgs.push(msg);
|
|
73
|
-
} : () => {};
|
|
100
|
+
const note = debug ? (msg) => msgs.push(msg) : () => {};
|
|
74
101
|
const flush = debug ? (final) => {
|
|
75
102
|
if (msgs.length === 0) {
|
|
76
103
|
console.log("[islands]", `<${tagName}> ${final}`);
|
|
77
104
|
} else {
|
|
78
|
-
console.groupCollapsed(`[islands] <${tagName}
|
|
105
|
+
console.groupCollapsed(`[islands] <${tagName}> ${final}`);
|
|
79
106
|
for (const m of msgs)
|
|
80
107
|
console.log(m);
|
|
81
|
-
console.log(final);
|
|
82
108
|
console.groupEnd();
|
|
83
109
|
}
|
|
84
110
|
} : () => {};
|
|
85
111
|
try {
|
|
86
|
-
|
|
87
|
-
|
|
112
|
+
const visibleAttr = el.getAttribute(attrVisible);
|
|
113
|
+
if (visibleAttr !== null) {
|
|
88
114
|
note(`waiting for ${attrVisible}`);
|
|
89
|
-
await visible(el,
|
|
115
|
+
await visible(el, visibleAttr || rootMargin, threshold, pendingVisible);
|
|
90
116
|
}
|
|
91
117
|
const query = el.getAttribute(attrMedia);
|
|
92
|
-
if (query) {
|
|
118
|
+
if (query === "") {
|
|
119
|
+
console.warn(`[islands] <${tagName}> ${attrMedia} has no value — skipping media check`);
|
|
120
|
+
} else if (query) {
|
|
93
121
|
note(`waiting for ${attrMedia}="${query}"`);
|
|
94
122
|
await media(query);
|
|
95
123
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
124
|
+
const idleAttr = el.getAttribute(attrIdle);
|
|
125
|
+
if (idleAttr !== null) {
|
|
126
|
+
const raw = parseInt(idleAttr, 10);
|
|
127
|
+
const elTimeout = Number.isNaN(raw) ? idleTimeout : raw;
|
|
128
|
+
note(`waiting for ${attrIdle} (${elTimeout}ms)`);
|
|
100
129
|
await idle(elTimeout);
|
|
101
130
|
}
|
|
102
131
|
const d = el.getAttribute(attrDefer);
|
|
@@ -113,7 +142,11 @@ function revive(islands, options, customDirectives) {
|
|
|
113
142
|
flush("aborted (element removed)");
|
|
114
143
|
return;
|
|
115
144
|
}
|
|
116
|
-
const run = () => loader().
|
|
145
|
+
const run = () => loader().then(() => {
|
|
146
|
+
loaded.add(tagName);
|
|
147
|
+
if (el.children.length)
|
|
148
|
+
walk(el);
|
|
149
|
+
}).catch((err) => {
|
|
117
150
|
console.error(`[islands] Failed to load <${tagName}>:`, err);
|
|
118
151
|
queued.delete(tagName);
|
|
119
152
|
});
|
|
@@ -141,10 +174,16 @@ function revive(islands, options, customDirectives) {
|
|
|
141
174
|
if (queued.has(tagName))
|
|
142
175
|
return;
|
|
143
176
|
const loader = islandMap.get(tagName);
|
|
144
|
-
if (loader)
|
|
145
|
-
|
|
146
|
-
|
|
177
|
+
if (!loader)
|
|
178
|
+
return;
|
|
179
|
+
let ancestor = el.parentElement;
|
|
180
|
+
while (ancestor) {
|
|
181
|
+
if (isUnloadedIsland(ancestor.tagName.toLowerCase()))
|
|
182
|
+
return;
|
|
183
|
+
ancestor = ancestor.parentElement;
|
|
147
184
|
}
|
|
185
|
+
queued.add(tagName);
|
|
186
|
+
loadIsland(tagName, el, loader);
|
|
148
187
|
}
|
|
149
188
|
function walk(el) {
|
|
150
189
|
activate(el);
|
|
@@ -168,7 +207,12 @@ function revive(islands, options, customDirectives) {
|
|
|
168
207
|
}
|
|
169
208
|
});
|
|
170
209
|
function init() {
|
|
210
|
+
if (debug)
|
|
211
|
+
console.groupCollapsed(`[islands] ready — ${islandMap.size} island(s)`);
|
|
171
212
|
walk(document.body);
|
|
213
|
+
initDone = true;
|
|
214
|
+
if (debug)
|
|
215
|
+
console.groupEnd();
|
|
172
216
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
173
217
|
}
|
|
174
218
|
if (document.readyState === "loading") {
|
|
@@ -176,6 +220,7 @@ function revive(islands, options, customDirectives) {
|
|
|
176
220
|
} else {
|
|
177
221
|
init();
|
|
178
222
|
}
|
|
223
|
+
return () => observer.disconnect();
|
|
179
224
|
}
|
|
180
225
|
export {
|
|
181
226
|
revive
|
package/package.json
CHANGED
package/revive.d.ts
CHANGED