@occluze/tether 0.6.8 → 0.7.0
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 +11 -11
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
- package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
- package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
- package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
- package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
- package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
- package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
- package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
- package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
- package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
- package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
- package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
- package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
- package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
- package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
- package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
- package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
- package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -2
- package/dist/presets/barba.esm.js +19 -23
- package/dist/tether.account-pages.js +320 -365
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +242 -491
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +66 -88
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +36 -33
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +28 -36
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +1270 -894
- package/dist/tether.markets.js +73 -90
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +44 -68
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/account-orders.d.ts +44 -0
- package/dist/types/server/src/shared/account-routes.d.ts +153 -0
- package/dist/types/server/src/shared/api-versions.d.ts +13 -0
- package/dist/types/server/src/shared/error-message.d.ts +2 -0
- package/dist/types/server/src/shared/json.d.ts +25 -0
- package/dist/types/server/src/shared/market-codes.d.ts +16 -0
- package/dist/types/src/account/addresses-view.d.ts +5 -0
- package/dist/types/src/account/answers.d.ts +40 -0
- package/dist/types/src/account/markup.d.ts +19 -0
- package/dist/types/src/account/operations.d.ts +41 -0
- package/dist/types/src/account/order-lines.d.ts +13 -0
- package/dist/types/src/account/order-list.d.ts +12 -0
- package/dist/types/src/account/order-page.d.ts +6 -0
- package/dist/types/src/account/page-answers.d.ts +182 -0
- package/dist/types/src/account/page-events.d.ts +2 -0
- package/dist/types/src/account/server-client.d.ts +60 -0
- package/dist/types/src/account/subscriptions-view.d.ts +2 -0
- package/dist/types/src/account-api.d.ts +18 -0
- package/dist/types/src/account-marks.d.ts +15 -0
- package/dist/types/src/account-pages.d.ts +8 -0
- package/dist/types/src/account.d.ts +64 -0
- package/dist/types/src/amounts.d.ts +12 -0
- package/dist/types/src/analytics-shopify.d.ts +41 -0
- package/dist/types/src/analytics.d.ts +2 -0
- package/dist/types/src/api-version.d.ts +28 -0
- package/dist/types/src/astro.d.ts +42 -0
- package/dist/types/src/cart/cart-shape.d.ts +43 -0
- package/dist/types/src/cart/checkout.d.ts +16 -0
- package/dist/types/src/cart/drawer.d.ts +18 -0
- package/dist/types/src/cart/mutations.d.ts +79 -0
- package/dist/types/src/cart/render.d.ts +17 -0
- package/dist/types/src/cart/store.d.ts +182 -0
- package/dist/types/src/cart.d.ts +106 -0
- package/dist/types/src/client.d.ts +100 -0
- package/dist/types/src/collection.d.ts +25 -0
- package/dist/types/src/config-sections.d.ts +40 -0
- package/dist/types/src/config.d.ts +185 -0
- package/dist/types/src/controls/cart-controls.d.ts +2 -0
- package/dist/types/src/controls/discount.d.ts +2 -0
- package/dist/types/src/controls/extras.d.ts +5 -0
- package/dist/types/src/controls/navigation.d.ts +12 -0
- package/dist/types/src/diagnose-markup.d.ts +19 -0
- package/dist/types/src/diagnose.d.ts +60 -0
- package/dist/types/src/dom.d.ts +75 -0
- package/dist/types/src/extras.d.ts +21 -0
- package/dist/types/src/fragments.d.ts +158 -0
- package/dist/types/src/globals.d.ts +15 -0
- package/dist/types/src/images.d.ts +20 -0
- package/dist/types/src/index.d.ts +167 -0
- package/dist/types/src/initial.d.ts +56 -0
- package/dist/types/src/lazy.d.ts +28 -0
- package/dist/types/src/loader.d.ts +84 -0
- package/dist/types/src/market-codes.d.ts +6 -0
- package/dist/types/src/markets-ui.d.ts +32 -0
- package/dist/types/src/markets.d.ts +72 -0
- package/dist/types/src/markup.d.ts +131 -0
- package/dist/types/src/mounting.d.ts +46 -0
- package/dist/types/src/page-market.d.ts +27 -0
- package/dist/types/src/presets/barba.d.ts +56 -0
- package/dist/types/src/product-events.d.ts +2 -0
- package/dist/types/src/product-render.d.ts +17 -0
- package/dist/types/src/product-shape.d.ts +88 -0
- package/dist/types/src/product-state.d.ts +110 -0
- package/dist/types/src/product.d.ts +66 -0
- package/dist/types/src/repeaters.d.ts +16 -0
- package/dist/types/src/search.d.ts +33 -0
- package/dist/types/src/selling-plans.d.ts +64 -0
- package/dist/types/src/server.d.ts +88 -0
- package/dist/types/src/shopify-values.d.ts +46 -0
- package/dist/types/src/status.d.ts +33 -0
- package/dist/types/src/storage.d.ts +12 -0
- package/dist/types/src/store-html.d.ts +39 -0
- package/dist/types/src/storefront/catalog.d.ts +38 -0
- package/dist/types/src/storefront/json-ld.d.ts +20 -0
- package/dist/types/src/variant-render.d.ts +14 -0
- package/package.json +24 -23
- package/src/account/addresses-view.ts +58 -0
- package/src/account/answers.ts +79 -0
- package/src/account/markup.ts +36 -0
- package/src/account/operations.ts +145 -0
- package/src/account/{order-lines.js → order-lines.ts} +20 -41
- package/src/account/order-list.ts +107 -0
- package/src/account/order-page.ts +137 -0
- package/src/account/page-answers.ts +317 -0
- package/src/account/{page-events.js → page-events.ts} +55 -90
- package/src/account/server-client.ts +183 -0
- package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
- package/src/account-api.ts +98 -0
- package/src/account-marks.ts +35 -0
- package/src/{account-pages.js → account-pages.ts} +29 -37
- package/src/account.ts +284 -0
- package/src/amounts.ts +64 -0
- package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
- package/src/analytics.ts +315 -0
- package/src/{api-version.js → api-version.ts} +25 -31
- package/src/{astro.js → astro.ts} +110 -104
- package/src/cart/cart-shape.ts +25 -0
- package/src/cart/checkout.ts +101 -0
- package/src/cart/{drawer.js → drawer.ts} +18 -31
- package/src/cart/mutations.ts +237 -0
- package/src/cart/{render.js → render.ts} +68 -96
- package/src/cart/store.ts +279 -0
- package/src/{cart.js → cart.ts} +82 -128
- package/src/client.ts +391 -0
- package/src/{collection.js → collection.ts} +30 -38
- package/src/config-sections.ts +284 -0
- package/src/config.ts +480 -0
- package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
- package/src/controls/{discount.js → discount.ts} +13 -34
- package/src/controls/{extras.js → extras.ts} +15 -24
- package/src/controls/{navigation.js → navigation.ts} +24 -30
- package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
- package/src/{diagnose.js → diagnose.ts} +95 -96
- package/src/dom.ts +268 -0
- package/src/{extras.js → extras.ts} +43 -52
- package/src/fragments.ts +261 -0
- package/src/globals.d.ts +15 -0
- package/src/{images.js → images.ts} +30 -43
- package/src/index.ts +415 -0
- package/src/initial.ts +101 -0
- package/src/lazy-iife.ts +197 -0
- package/src/lazy.ts +42 -0
- package/src/{loader.js → loader.ts} +149 -123
- package/src/market-codes.ts +6 -0
- package/src/{markets-ui.js → markets-ui.ts} +91 -68
- package/src/markets.ts +246 -0
- package/src/markup.ts +127 -0
- package/src/{mounting.js → mounting.ts} +88 -101
- package/src/{page-market.js → page-market.ts} +32 -38
- package/src/part-integrity.ts +7 -0
- package/src/parts/account-pages.ts +7 -0
- package/src/parts/account.ts +7 -0
- package/src/parts/analytics.ts +7 -0
- package/src/parts/diagnose.ts +7 -0
- package/src/parts/extras.ts +7 -0
- package/src/parts/markets.ts +7 -0
- package/src/parts/search.ts +7 -0
- package/src/presets/barba.ts +194 -0
- package/src/{product-events.js → product-events.ts} +33 -48
- package/src/{product-render.js → product-render.ts} +38 -77
- package/src/product-shape.ts +49 -0
- package/src/product-state.ts +318 -0
- package/src/{product.js → product.ts} +90 -126
- package/src/{repeaters.js → repeaters.ts} +40 -52
- package/src/{search.js → search.ts} +85 -103
- package/src/selling-plans.ts +145 -0
- package/src/server.ts +211 -0
- package/src/{shopify-values.js → shopify-values.ts} +24 -22
- package/src/{status.js → status.ts} +34 -54
- package/src/storage.ts +34 -0
- package/src/store-html.ts +359 -0
- package/src/storefront/catalog.ts +196 -0
- package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
- package/src/{variant-render.js → variant-render.ts} +24 -50
- package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
- package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
- package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
- package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
- package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
- package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
- package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
- package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
- package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
- package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
- package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
- package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
- package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
- package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
- package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
- package/dist/esm/chunks/search-OU75RNQL.js +0 -1
- package/src/account/addresses-view.js +0 -92
- package/src/account/browser-session.js +0 -359
- package/src/account/markup.js +0 -48
- package/src/account/operations.js +0 -180
- package/src/account/order-list.js +0 -91
- package/src/account/order-page.js +0 -192
- package/src/account/server-client.js +0 -172
- package/src/account/shared.js +0 -111
- package/src/account/shopify-auth.js +0 -150
- package/src/account-api.js +0 -118
- package/src/account-links.js +0 -35
- package/src/account.js +0 -342
- package/src/amounts.js +0 -36
- package/src/analytics.js +0 -352
- package/src/cart/checkout.js +0 -97
- package/src/cart/mutations.js +0 -234
- package/src/cart/store.js +0 -326
- package/src/client.js +0 -267
- package/src/config.js +0 -349
- package/src/dom.js +0 -287
- package/src/fragments.js +0 -237
- package/src/index.js +0 -353
- package/src/initial.js +0 -87
- package/src/lazy-iife.js +0 -116
- package/src/lazy.js +0 -39
- package/src/market-codes.js +0 -35
- package/src/markets.js +0 -334
- package/src/markup.js +0 -64
- package/src/parts/account-pages.js +0 -6
- package/src/parts/account.js +0 -6
- package/src/parts/analytics.js +0 -6
- package/src/parts/diagnose.js +0 -6
- package/src/parts/extras.js +0 -6
- package/src/parts/markets.js +0 -6
- package/src/parts/search.js +0 -6
- package/src/presets/barba.js +0 -190
- package/src/product-state.js +0 -318
- package/src/selling-plans.js +0 -154
- package/src/server.js +0 -215
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +0 -199
- package/types/astro.d.ts +0 -28
- package/types/barba.d.ts +0 -16
- package/types/index.d.ts +0 -577
- package/types/server.d.ts +0 -182
|
@@ -3,58 +3,75 @@
|
|
|
3
3
|
* (`window.Shopify.analytics`) and loads Shopify's consent script and shopify.js, which build and send the events.
|
|
4
4
|
* Nothing is delivered before the Customer Privacy API allows analytics.
|
|
5
5
|
*/
|
|
6
|
-
import { cfg, log, reportError } from './config.
|
|
7
|
-
import { gql } from './client.
|
|
8
|
-
|
|
9
|
-
/** @typedef {Record<string, unknown>} EventPayload */
|
|
10
|
-
/** @typedef {{ uniqueToken: string, visitToken: string }} TrackingValues */
|
|
11
|
-
/** @typedef {{ getTrackingValues: () => TrackingValues }} DestinationContext */
|
|
12
|
-
/** @typedef {(payload: EventPayload, context: DestinationContext) => void} Subscriber */
|
|
13
|
-
/** @typedef {(event: string, subscriber: Subscriber) => () => void} Subscribe */
|
|
14
|
-
/** @typedef {{ shop: { shopId: string, channel: 'headless' }, consent: { mode: string } }} BusConfig */
|
|
15
|
-
/**
|
|
16
|
-
* @typedef {object} DestinationSpec What shopify.js registers.
|
|
17
|
-
* @property {string} name
|
|
18
|
-
* @property {(api: { subscribe: Subscribe, getConfig: () => BusConfig }) => unknown} [setup]
|
|
19
|
-
*/
|
|
20
|
-
/**
|
|
21
|
-
* @typedef {object} AnalyticsBus Hydrogen's `window.Shopify.analytics`.
|
|
22
|
-
* @property {() => BusConfig} getConfig
|
|
23
|
-
* @property {(event: string, payload?: EventPayload) => void} publish
|
|
24
|
-
* @property {(destination: DestinationSpec) => () => void} addDestination
|
|
25
|
-
*/
|
|
6
|
+
import { cfg, log, reportError } from './config.ts';
|
|
7
|
+
import { gql, shaped } from './client.ts';
|
|
8
|
+
import type { AnalyticsConfig } from './config.ts';
|
|
26
9
|
|
|
27
|
-
|
|
28
|
-
* The parts of Shopify's Customer Privacy API (`window.Shopify.customerPrivacy`) that decide tracking.
|
|
29
|
-
*
|
|
30
|
-
* @typedef {object} CustomerPrivacy
|
|
31
|
-
* @property {string} [consentStatus]
|
|
32
|
-
* @property {() => { analytics?: string } | undefined} [currentVisitorConsent]
|
|
33
|
-
* @property {() => boolean} [analyticsProcessingAllowed]
|
|
34
|
-
* @property {{ uniqueToken?: TokenReader, visitToken?: TokenReader }} [__internal]
|
|
35
|
-
* @property {object} [config]
|
|
36
|
-
*/
|
|
37
|
-
/** @typedef {(options: { generateFallback: boolean, tag: string }) => string} TokenReader */
|
|
38
|
-
/**
|
|
39
|
-
* `window.Shopify`, which Shopify's scripts read and extend.
|
|
40
|
-
*
|
|
41
|
-
* @typedef {object} ShopifyGlobal
|
|
42
|
-
* @property {AnalyticsBus} [analytics]
|
|
43
|
-
* @property {CustomerPrivacy} [customerPrivacy]
|
|
44
|
-
* @property {string} [country]
|
|
45
|
-
* @property {string} [locale]
|
|
46
|
-
* @property {string} [shop]
|
|
47
|
-
* @property {object} [routes]
|
|
48
|
-
*/
|
|
10
|
+
export type EventPayload = Record<string, unknown>;
|
|
49
11
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
12
|
+
interface TrackingValues {
|
|
13
|
+
uniqueToken: string;
|
|
14
|
+
visitToken: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface DestinationContext {
|
|
18
|
+
getTrackingValues: () => TrackingValues;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Subscriber = (payload: EventPayload, context: DestinationContext) => void;
|
|
22
|
+
type Subscribe = (event: string, subscriber: Subscriber) => () => void;
|
|
23
|
+
|
|
24
|
+
interface BusConfig {
|
|
25
|
+
shop: { shopId: string; channel: 'headless' };
|
|
26
|
+
consent: { mode: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What shopify.js registers. */
|
|
30
|
+
interface DestinationSpec {
|
|
31
|
+
name: string;
|
|
32
|
+
setup?: (api: { subscribe: Subscribe; getConfig: () => BusConfig }) => unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Hydrogen's `window.Shopify.analytics`. */
|
|
36
|
+
export interface AnalyticsBus {
|
|
37
|
+
getConfig: () => BusConfig;
|
|
38
|
+
publish: (event: string, payload?: EventPayload) => void;
|
|
39
|
+
addDestination: (destination: DestinationSpec) => () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type TokenReader = (options: { generateFallback: boolean; tag: string }) => string;
|
|
43
|
+
|
|
44
|
+
/** The parts of Shopify's Customer Privacy API (`window.Shopify.customerPrivacy`) that decide tracking. */
|
|
45
|
+
interface CustomerPrivacy {
|
|
46
|
+
consentStatus?: string;
|
|
47
|
+
currentVisitorConsent?: () => { analytics?: string } | undefined;
|
|
48
|
+
analyticsProcessingAllowed?: () => boolean;
|
|
49
|
+
__internal?: { uniqueToken?: TokenReader; visitToken?: TokenReader };
|
|
50
|
+
config?: object;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `window.Shopify`, which Shopify's scripts read and extend. */
|
|
54
|
+
interface ShopifyGlobal {
|
|
55
|
+
analytics?: AnalyticsBus;
|
|
56
|
+
customerPrivacy?: CustomerPrivacy;
|
|
57
|
+
country?: string;
|
|
58
|
+
locale?: string;
|
|
59
|
+
shop?: string;
|
|
60
|
+
routes?: object;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface BufferedEvent {
|
|
64
|
+
event: string;
|
|
65
|
+
payload: EventPayload;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Destination {
|
|
69
|
+
name: string;
|
|
70
|
+
/** The index of the next buffered event it gets. */
|
|
71
|
+
next: number;
|
|
72
|
+
subscribers: Map<string, Set<Subscriber>>;
|
|
73
|
+
context: DestinationContext;
|
|
74
|
+
}
|
|
58
75
|
|
|
59
76
|
/** Events kept for destinations that register late (shopify.js loads after the first page view). */
|
|
60
77
|
const BUFFER_SIZE = 500;
|
|
@@ -64,15 +81,13 @@ const CONSENT_SCRIPTS = {
|
|
|
64
81
|
};
|
|
65
82
|
const ANALYTICS_SCRIPT = 'https://cdn.shopify.com/storefront/analytics/shopify.js';
|
|
66
83
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
/** @type {unknown} */
|
|
70
|
-
const shopify = Reflect.get(window, 'Shopify');
|
|
71
|
-
return /** @type {ShopifyGlobal | undefined} */ (shopify);
|
|
72
|
-
}
|
|
84
|
+
/** The page, where Shopify's scripts keep `window.Shopify` in the shape they define. */
|
|
85
|
+
const page = (): Window & { Shopify?: ShopifyGlobal } => window;
|
|
73
86
|
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
const shopifyGlobal = (): ShopifyGlobal | undefined => page().Shopify;
|
|
88
|
+
|
|
89
|
+
/** The `analytics.shopify` options; `true` sets none. */
|
|
90
|
+
function shopifyOptions(): Exclude<NonNullable<AnalyticsConfig['shopify']>, boolean> {
|
|
76
91
|
const shopify = cfg.analytics?.shopify;
|
|
77
92
|
return typeof shopify === 'object' ? shopify : {};
|
|
78
93
|
}
|
|
@@ -80,10 +95,8 @@ function shopifyOptions() {
|
|
|
80
95
|
/**
|
|
81
96
|
* Shopify's privacy API can throw while its script is still setting up. An answer it can't give counts as "not
|
|
82
97
|
* allowed", so nothing is sent without consent; the next consent event asks again.
|
|
83
|
-
*
|
|
84
|
-
* @returns {boolean}
|
|
85
98
|
*/
|
|
86
|
-
function trackingAllowed() {
|
|
99
|
+
function trackingAllowed(): boolean {
|
|
87
100
|
try {
|
|
88
101
|
const privacy = shopifyGlobal()?.customerPrivacy;
|
|
89
102
|
return (
|
|
@@ -96,11 +109,8 @@ function trackingAllowed() {
|
|
|
96
109
|
}
|
|
97
110
|
}
|
|
98
111
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
* @returns {DestinationContext}
|
|
102
|
-
*/
|
|
103
|
-
function destinationContext(name) {
|
|
112
|
+
/** @param name The destination's name, Hydrogen tags its tokens with it. */
|
|
113
|
+
function destinationContext(name: string): DestinationContext {
|
|
104
114
|
const tag = `hydrogen:${name}`;
|
|
105
115
|
return {
|
|
106
116
|
getTrackingValues: () => {
|
|
@@ -113,28 +123,20 @@ function destinationContext(name) {
|
|
|
113
123
|
};
|
|
114
124
|
}
|
|
115
125
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return () => destination.subscribers.get(event)?.delete(subscriber);
|
|
125
|
-
};
|
|
126
|
+
const subscriberOf =
|
|
127
|
+
(destination: Destination): Subscribe =>
|
|
128
|
+
(event, subscriber) => {
|
|
129
|
+
const subscribers = destination.subscribers.get(event) ?? new Set();
|
|
130
|
+
destination.subscribers.set(event, subscribers);
|
|
131
|
+
subscribers.add(subscriber);
|
|
132
|
+
return () => destination.subscribers.get(event)?.delete(subscriber);
|
|
133
|
+
};
|
|
126
134
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
/** @type {Destination[]} */
|
|
133
|
-
const destinations = [];
|
|
134
|
-
/** @type {BufferedEvent[]} */
|
|
135
|
-
const buffer = [];
|
|
136
|
-
/** @type {BusConfig} */
|
|
137
|
-
const config = {
|
|
135
|
+
/** A bus that buffers events and delivers them to each destination once consent allows. */
|
|
136
|
+
function createBus(shopId: string): AnalyticsBus {
|
|
137
|
+
const destinations: Destination[] = [];
|
|
138
|
+
const buffer: BufferedEvent[] = [];
|
|
139
|
+
const config: BusConfig = {
|
|
138
140
|
shop: { shopId, channel: 'headless' },
|
|
139
141
|
consent: { mode: shopifyOptions().consent || 'no-banner' },
|
|
140
142
|
};
|
|
@@ -147,8 +149,7 @@ function createBus(shopId) {
|
|
|
147
149
|
}
|
|
148
150
|
}
|
|
149
151
|
};
|
|
150
|
-
|
|
151
|
-
const bus = {
|
|
152
|
+
const bus: AnalyticsBus = {
|
|
152
153
|
getConfig: () => config,
|
|
153
154
|
publish(event, payload = {}) {
|
|
154
155
|
buffer.push({ event, payload: { shop: config.shop, url: window.location.href, ...payload } });
|
|
@@ -159,14 +160,18 @@ function createBus(shopId) {
|
|
|
159
160
|
deliver();
|
|
160
161
|
},
|
|
161
162
|
addDestination(spec) {
|
|
162
|
-
|
|
163
|
-
|
|
163
|
+
const destination: Destination = {
|
|
164
|
+
name: spec.name,
|
|
165
|
+
next: 0,
|
|
166
|
+
subscribers: new Map(),
|
|
167
|
+
context: destinationContext(spec.name),
|
|
168
|
+
};
|
|
164
169
|
Promise.resolve(spec.setup?.({ subscribe: subscriberOf(destination), getConfig: bus.getConfig }))
|
|
165
170
|
.then(() => {
|
|
166
171
|
destinations.push(destination);
|
|
167
172
|
deliver();
|
|
168
173
|
})
|
|
169
|
-
.catch((
|
|
174
|
+
.catch((error: unknown) => {
|
|
170
175
|
reportError(error, `analytics ${spec.name}`);
|
|
171
176
|
});
|
|
172
177
|
return () => {
|
|
@@ -180,11 +185,7 @@ function createBus(shopId) {
|
|
|
180
185
|
return bus;
|
|
181
186
|
}
|
|
182
187
|
|
|
183
|
-
|
|
184
|
-
* @param {Destination} destination
|
|
185
|
-
* @param {BufferedEvent} buffered
|
|
186
|
-
*/
|
|
187
|
-
function deliverTo(destination, { event, payload }) {
|
|
188
|
+
function deliverTo(destination: Destination, { event, payload }: BufferedEvent): void {
|
|
188
189
|
destination.subscribers.get(event)?.forEach((subscriber) => {
|
|
189
190
|
try {
|
|
190
191
|
subscriber(payload, destination.context);
|
|
@@ -194,8 +195,7 @@ function deliverTo(destination, { event, payload }) {
|
|
|
194
195
|
});
|
|
195
196
|
}
|
|
196
197
|
|
|
197
|
-
|
|
198
|
-
function loadScript(src) {
|
|
198
|
+
function loadScript(src: string): void {
|
|
199
199
|
const script = document.createElement('script');
|
|
200
200
|
script.src = src;
|
|
201
201
|
script.async = true;
|
|
@@ -205,19 +205,24 @@ function loadScript(src) {
|
|
|
205
205
|
/**
|
|
206
206
|
* Sets up `window.Shopify` for Shopify's scripts and loads them.
|
|
207
207
|
*
|
|
208
|
-
* @param
|
|
209
|
-
* @returns
|
|
208
|
+
* @param isProductPage Asked by Shopify's script for the page template.
|
|
209
|
+
* @returns The bus, the page's own when a theme already set one up.
|
|
210
210
|
*/
|
|
211
|
-
export async function startShopifyAnalytics(isProductPage) {
|
|
211
|
+
export async function startShopifyAnalytics(isProductPage: () => boolean): Promise<AnalyticsBus> {
|
|
212
212
|
const existing = shopifyGlobal();
|
|
213
213
|
if (existing?.analytics) return existing.analytics;
|
|
214
|
-
|
|
215
|
-
|
|
214
|
+
const data = await gql(
|
|
215
|
+
'query TetherShopId { shop { id } }',
|
|
216
|
+
{},
|
|
217
|
+
shaped<{ shop: { id: string } }>({ shop: { id: 'string' } }),
|
|
218
|
+
{ context: false },
|
|
219
|
+
);
|
|
216
220
|
const shopify = shopifyGlobal() ?? {};
|
|
217
|
-
|
|
218
|
-
shopify.country =
|
|
221
|
+
page().Shopify = shopify;
|
|
222
|
+
shopify.country = cfg.country ?? 'US';
|
|
219
223
|
// Shopify's script requires a language, not a full locale.
|
|
220
|
-
|
|
224
|
+
const [language = 'en'] = (cfg.locale || 'en').split('-');
|
|
225
|
+
shopify.locale = language.toLowerCase();
|
|
221
226
|
shopify.shop = cfg.domain;
|
|
222
227
|
shopify.routes = {
|
|
223
228
|
root: '/',
|
package/src/analytics.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics, as an optional part (loaded when config.analytics is set).
|
|
3
|
+
*
|
|
4
|
+
* analytics: {
|
|
5
|
+
* dataLayer: true, // GA4 / Google Tag Manager ecommerce events (window.dataLayer)
|
|
6
|
+
* shopify: true, // Shopify's own analytics (experimental, see docs/analytics.md)
|
|
7
|
+
* onEvent(name, data) {}, // any other tool: Meta, TikTok, Plausible
|
|
8
|
+
* }
|
|
9
|
+
*
|
|
10
|
+
* Events: view_item (the page's product), view_item_list (lists), add_to_cart / remove_from_cart (from the cart's
|
|
11
|
+
* changes, so quick +/- and JS calls count too), view_cart (cart opened), begin_checkout, search (search as you type).
|
|
12
|
+
* Purchases happen on Shopify's checkout: use Shopify's Google & YouTube app or a checkout pixel for those.
|
|
13
|
+
* Shopify's analytics are set up in analytics-shopify.ts.
|
|
14
|
+
*/
|
|
15
|
+
import { cfg, reportError } from './config.ts';
|
|
16
|
+
import { ANALYTICS_EVENTS, isOwnEvent, sel } from './dom.ts';
|
|
17
|
+
import { Cart } from './cart.ts';
|
|
18
|
+
import { scopes } from './product.ts';
|
|
19
|
+
import { startShopifyAnalytics } from './analytics-shopify.ts';
|
|
20
|
+
import type { Money } from './shopify-values.ts';
|
|
21
|
+
import type { Product, ProductVariant } from './fragments.ts';
|
|
22
|
+
import type { AnalyticsBus, EventPayload } from './analytics-shopify.ts';
|
|
23
|
+
import type { ProductScope } from './product.ts';
|
|
24
|
+
import type { AnalyticsEventName, TetherEvents } from './markup.ts';
|
|
25
|
+
|
|
26
|
+
/** The fields of the cart's product (`fragment TetherCart`) this module reads. */
|
|
27
|
+
interface LineProduct {
|
|
28
|
+
id: string;
|
|
29
|
+
title: string;
|
|
30
|
+
handle: string;
|
|
31
|
+
vendor: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface LineVariant {
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
sku: string | null;
|
|
38
|
+
price: Money;
|
|
39
|
+
product: LineProduct;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CartLine {
|
|
43
|
+
id: string;
|
|
44
|
+
quantity: number;
|
|
45
|
+
merchandise: LineVariant;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The fields of the cart (`fragment TetherCart`) this module reads. */
|
|
49
|
+
interface AnalyticsCart {
|
|
50
|
+
lines: { nodes: CartLine[] };
|
|
51
|
+
cost: { totalAmount: Money };
|
|
52
|
+
discountCodes: { code: string; applicable: boolean }[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What a GA4 item is built from: a product, or a cart line's product. */
|
|
56
|
+
interface ItemProduct {
|
|
57
|
+
id: string;
|
|
58
|
+
title: string;
|
|
59
|
+
vendor?: string;
|
|
60
|
+
productType?: string;
|
|
61
|
+
priceRange?: { minVariantPrice: Money };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type ItemVariant = Pick<ProductVariant, 'id' | 'title' | 'sku' | 'price'>;
|
|
65
|
+
|
|
66
|
+
/** A GA4 ecommerce item. */
|
|
67
|
+
interface AnalyticsItem {
|
|
68
|
+
item_id: string;
|
|
69
|
+
item_name: string;
|
|
70
|
+
item_variant: string | undefined;
|
|
71
|
+
item_brand: string | undefined;
|
|
72
|
+
item_category: string | undefined;
|
|
73
|
+
price: number;
|
|
74
|
+
quantity: number;
|
|
75
|
+
index?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Shopify's title for the one variant of a product without options. */
|
|
79
|
+
const DEFAULT_VARIANT_TITLE = 'Default Title';
|
|
80
|
+
const CENTS_PER_UNIT = 100;
|
|
81
|
+
/** GA4 drops an event with more items than this. */
|
|
82
|
+
const MAX_LIST_ITEMS = 200;
|
|
83
|
+
|
|
84
|
+
const amountOf = (money: Money | null | undefined): number => (money ? parseFloat(money.amount) : 0);
|
|
85
|
+
|
|
86
|
+
/** The numeric part of a gid. */
|
|
87
|
+
const numericId = (gid: string | null | undefined): string => (gid ?? '').split('/').pop() ?? '';
|
|
88
|
+
|
|
89
|
+
/** @param index The position in a list. */
|
|
90
|
+
function itemFromVariant(
|
|
91
|
+
product: ItemProduct,
|
|
92
|
+
variant: ItemVariant | null | undefined,
|
|
93
|
+
quantity = 1,
|
|
94
|
+
index?: number,
|
|
95
|
+
): AnalyticsItem {
|
|
96
|
+
const item: AnalyticsItem = {
|
|
97
|
+
item_id: variant?.sku || numericId(variant?.id) || numericId(product.id),
|
|
98
|
+
item_name: product.title,
|
|
99
|
+
item_variant: variant?.title === DEFAULT_VARIANT_TITLE ? undefined : variant?.title,
|
|
100
|
+
item_brand: product.vendor || undefined,
|
|
101
|
+
item_category: product.productType || undefined,
|
|
102
|
+
price: amountOf(variant?.price ?? product.priceRange?.minVariantPrice),
|
|
103
|
+
quantity,
|
|
104
|
+
};
|
|
105
|
+
if (index != null) item.index = index;
|
|
106
|
+
return item;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const itemFromLine = (line: CartLine, quantity = line.quantity) =>
|
|
110
|
+
itemFromVariant(line.merchandise.product, line.merchandise, quantity);
|
|
111
|
+
|
|
112
|
+
/** The items' total, rounded to cents. */
|
|
113
|
+
function totalValue(items: AnalyticsItem[]): number {
|
|
114
|
+
const total = items.reduce((sum, item) => sum + item.price * (item.quantity || 1), 0);
|
|
115
|
+
return Math.round(total * CENTS_PER_UNIT) / CENTS_PER_UNIT;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const productPayload = (product: Product, variant: ItemVariant, quantity = 1) => ({
|
|
119
|
+
id: product.id,
|
|
120
|
+
title: product.title,
|
|
121
|
+
price: variant.price,
|
|
122
|
+
vendor: product.vendor || '',
|
|
123
|
+
variantId: variant.id,
|
|
124
|
+
variantTitle: variant.title,
|
|
125
|
+
quantity,
|
|
126
|
+
sku: variant.sku || undefined,
|
|
127
|
+
productType: product.productType || undefined,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const linePayload = (line: CartLine) => ({
|
|
131
|
+
id: line.id,
|
|
132
|
+
quantity: line.quantity,
|
|
133
|
+
merchandise: {
|
|
134
|
+
id: line.merchandise.id,
|
|
135
|
+
title: line.merchandise.title,
|
|
136
|
+
price: line.merchandise.price,
|
|
137
|
+
sku: line.merchandise.sku,
|
|
138
|
+
product: {
|
|
139
|
+
id: line.merchandise.product.id,
|
|
140
|
+
title: line.merchandise.product.title,
|
|
141
|
+
vendor: line.merchandise.product.vendor || '',
|
|
142
|
+
handle: line.merchandise.product.handle,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/** The GA4 / GTM data layer, created when the page has none. */
|
|
148
|
+
function dataLayer(): unknown[] {
|
|
149
|
+
const { dataLayer: option } = cfg.analytics ?? {};
|
|
150
|
+
const name = typeof option === 'string' ? option : 'dataLayer';
|
|
151
|
+
const existing: unknown = Reflect.get(window, name);
|
|
152
|
+
if (Array.isArray(existing)) return existing;
|
|
153
|
+
const created: unknown[] = [];
|
|
154
|
+
Reflect.set(window, name, created);
|
|
155
|
+
return created;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function pushToDataLayer(event: string, ecommerce: Record<string, unknown> | undefined): void {
|
|
159
|
+
const layer = dataLayer();
|
|
160
|
+
if (!ecommerce) {
|
|
161
|
+
layer.push({ event });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Google asks for the previous ecommerce object to be cleared first.
|
|
165
|
+
layer.push({ ecommerce: null });
|
|
166
|
+
layer.push({ event, ecommerce });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let bus: AnalyticsBus | null = null;
|
|
170
|
+
/** Variant id to line, to turn cart changes into add and remove events. */
|
|
171
|
+
let lastLines: Map<string, CartLine> | null = null;
|
|
172
|
+
let lastCart: AnalyticsCart | null = null;
|
|
173
|
+
/** Product roots whose view_item was sent on this page. */
|
|
174
|
+
let viewed = new WeakSet<Element>();
|
|
175
|
+
|
|
176
|
+
/** @param name The GA4 event. */
|
|
177
|
+
function send(name: string, data: Record<string, unknown>, shopifyEvent?: string, shopifyPayload?: EventPayload): void {
|
|
178
|
+
if (cfg.analytics?.dataLayer) pushToDataLayer(name, data);
|
|
179
|
+
if (bus && shopifyEvent) bus.publish(shopifyEvent, shopifyPayload);
|
|
180
|
+
try {
|
|
181
|
+
cfg.analytics?.onEvent?.(name, data);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
reportError(error, 'analytics onEvent');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The scope of the page's own product: a product root outside every list. */
|
|
188
|
+
function mainProduct(): ProductScope | null {
|
|
189
|
+
const el = document.querySelector(sel('product'));
|
|
190
|
+
if (!el || el.closest(`${sel('collection')},${sel('products')},${sel('recommendations')}`)) return null;
|
|
191
|
+
return scopes.get(el) ?? null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function onProductReady({ product, scope }: TetherEvents['product-ready']): void {
|
|
195
|
+
if (viewed.has(scope.root) || mainProduct() !== scope) return;
|
|
196
|
+
viewed.add(scope.root);
|
|
197
|
+
const variant = scope.variant ?? product.variants.nodes[0];
|
|
198
|
+
const items = [itemFromVariant(product, variant)];
|
|
199
|
+
const products = variant ? [productPayload(product, variant)] : [];
|
|
200
|
+
send('view_item', { currency: variant?.price.currencyCode, value: totalValue(items), items }, 'product_viewed', {
|
|
201
|
+
products,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function onListReady({ products, collection }: TetherEvents['list-ready']): void {
|
|
206
|
+
const items = products
|
|
207
|
+
.slice(0, MAX_LIST_ITEMS)
|
|
208
|
+
.map((product, index) => itemFromVariant(product, product.variants.nodes[0], 1, index));
|
|
209
|
+
const list = { item_list_id: collection?.handle || 'products', item_list_name: collection?.title || 'Products' };
|
|
210
|
+
if (!collection) {
|
|
211
|
+
send('view_item_list', { ...list, items });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
send('view_item_list', { ...list, items }, 'collection_viewed', {
|
|
215
|
+
collection: { id: collection.id, handle: collection.handle },
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function sendCartChanges(cart: AnalyticsCart | null, now: Map<string, CartLine>, before: Map<string, CartLine>): void {
|
|
220
|
+
const currency = cart?.cost.totalAmount.currencyCode;
|
|
221
|
+
now.forEach((line, variantId) => {
|
|
222
|
+
const previous = before.get(variantId);
|
|
223
|
+
const added = line.quantity - (previous?.quantity ?? 0);
|
|
224
|
+
if (added <= 0) return;
|
|
225
|
+
const items = [itemFromLine(line, added)];
|
|
226
|
+
send('add_to_cart', { currency, value: totalValue(items), items }, 'product_added_to_cart', {
|
|
227
|
+
cart,
|
|
228
|
+
prevCart: lastCart,
|
|
229
|
+
currentLine: linePayload(line),
|
|
230
|
+
prevLine: previous ? linePayload(previous) : undefined,
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
before.forEach((line, variantId) => {
|
|
234
|
+
const removed = line.quantity - (now.get(variantId)?.quantity ?? 0);
|
|
235
|
+
if (removed <= 0) return;
|
|
236
|
+
const items = [itemFromLine(line, removed)];
|
|
237
|
+
send('remove_from_cart', { currency, value: totalValue(items), items });
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function onCartUpdated({ cart, reason }: TetherEvents['cart-updated']): void {
|
|
242
|
+
const now = new Map((cart?.lines.nodes ?? []).map((line) => [line.merchandise.id, line]));
|
|
243
|
+
// Loading the saved cart and showing a pending change aren't changes the visitor made.
|
|
244
|
+
if (lastLines && reason !== 'load' && reason !== 'pending') sendCartChanges(cart, now, lastLines);
|
|
245
|
+
lastLines = now;
|
|
246
|
+
lastCart = cart;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function onCartOpened(): void {
|
|
250
|
+
const items = Cart.lines.map((line) => itemFromLine(line));
|
|
251
|
+
send('view_cart', { currency: Cart.state?.cost.totalAmount.currencyCode, value: totalValue(items), items });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function onCheckout({ cart }: TetherEvents['checkout']): void {
|
|
255
|
+
if (!cart) return;
|
|
256
|
+
const items = cart.lines.nodes.map((line) => itemFromLine(line));
|
|
257
|
+
const coupons = cart.discountCodes.filter((discount) => discount.applicable).map((discount) => discount.code);
|
|
258
|
+
send('begin_checkout', {
|
|
259
|
+
currency: cart.cost.totalAmount.currencyCode,
|
|
260
|
+
value: totalValue(items),
|
|
261
|
+
coupon: coupons.join(',') || undefined,
|
|
262
|
+
items,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** What each event `ANALYTICS_EVENTS` names sends: one entry per name, so the list and this table can't diverge. */
|
|
267
|
+
const REPORTERS: { [Name in AnalyticsEventName]: (detail: TetherEvents[Name]) => void } = {
|
|
268
|
+
'product-ready': onProductReady,
|
|
269
|
+
'list-ready': onListReady,
|
|
270
|
+
'cart-updated': onCartUpdated,
|
|
271
|
+
'cart-toggled': ({ open }) => {
|
|
272
|
+
if (open) onCartOpened();
|
|
273
|
+
},
|
|
274
|
+
checkout: onCheckout,
|
|
275
|
+
search: ({ term }) => {
|
|
276
|
+
if (term) send('search', { search_term: term }, 'search_viewed', { searchTerm: term });
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** @param event The event Tether created as `name`. */
|
|
281
|
+
function report<Name extends AnalyticsEventName>(event: CustomEvent<TetherEvents[Name]>, name: Name): void {
|
|
282
|
+
REPORTERS[name](event.detail);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Turns one buffered event into analytics events: a Tether event from `ANALYTICS_EVENTS`, or Astro's `astro:page-load`
|
|
287
|
+
* for a client-side navigation, which starts a new page view. Only events Tether created count (`isOwnEvent`): a
|
|
288
|
+
* look-alike another script dispatches, whatever its detail, sends nothing.
|
|
289
|
+
*/
|
|
290
|
+
function handle(event: Event): void {
|
|
291
|
+
if (event.type === 'astro:page-load') {
|
|
292
|
+
viewed = new WeakSet();
|
|
293
|
+
bus?.publish('page_viewed', {});
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
ANALYTICS_EVENTS.forEach((name) => {
|
|
297
|
+
if (isOwnEvent(event, name)) report(event, name);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Called by the core with the events it buffered while this part loaded; the queue then handles events as they come. */
|
|
302
|
+
export async function startAnalytics(queue: Event[]): Promise<void> {
|
|
303
|
+
if (cfg.analytics?.shopify) {
|
|
304
|
+
bus = await startShopifyAnalytics(() => mainProduct() != null).catch((error: unknown) => {
|
|
305
|
+
reportError(error, 'shopify analytics');
|
|
306
|
+
return null;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
bus?.publish('page_viewed', {});
|
|
310
|
+
queue.splice(0).forEach(handle);
|
|
311
|
+
queue.push = (...events) => {
|
|
312
|
+
events.forEach(handle);
|
|
313
|
+
return queue.length;
|
|
314
|
+
};
|
|
315
|
+
}
|