@occluze/tether 0.7.0 → 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 -21
- package/dist/esm/chunks/{account-UIPWJQ3Y.js → account-CXMA5RPZ.js} +1 -1
- package/dist/esm/chunks/{account-pages-6ROA6Y4V.js → account-pages-IZO7FEOZ.js} +1 -1
- package/dist/esm/chunks/{analytics-2V632IRV.js → analytics-PG5FPRDN.js} +1 -1
- package/dist/esm/chunks/{chunk-4GCXM764.js → chunk-4HD4J5JC.js} +1 -1
- package/dist/esm/chunks/{chunk-BFTZR4OB.js → chunk-6TFMLT4A.js} +2 -2
- package/dist/esm/chunks/{chunk-T44MUL4Y.js → chunk-BBVUVPMO.js} +1 -1
- package/dist/esm/chunks/chunk-EW5SYO6L.js +1 -0
- package/dist/esm/chunks/chunk-HJY2JSNB.js +1 -0
- package/dist/esm/chunks/chunk-MQYB6BH4.js +1 -0
- package/dist/esm/chunks/{chunk-5Z5O3767.js → chunk-N3V45JPS.js} +1 -1
- package/dist/esm/chunks/{chunk-YLUULATY.js → chunk-RPMBCAGS.js} +1 -1
- package/dist/esm/chunks/{chunk-MPDWM2NN.js → chunk-VFNHIGCS.js} +1 -1
- package/dist/esm/chunks/{chunk-6OZJCAGZ.js → chunk-WT7RGGJU.js} +1 -1
- package/dist/esm/chunks/chunk-YN6LEBEN.js +1 -0
- package/dist/esm/chunks/chunk-Z6HS3H7K.js +1 -0
- package/dist/esm/chunks/{diagnose-3FEKRIEK.js → diagnose-XCEBQKBO.js} +1 -1
- package/dist/esm/chunks/extras-4HU3VSBQ.js +1 -0
- package/dist/esm/chunks/{markets-ui-FD6NI2OG.js → markets-ui-M4IQ62CT.js} +1 -1
- package/dist/esm/chunks/search-A5DS2IOS.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -2
- package/dist/tether.account-pages.js +13 -3
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +13 -3
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +18 -3
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +18 -3
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +19 -6
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +162 -112
- package/dist/tether.markets.js +18 -3
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +3 -3
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +18 -3
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/cart-id.d.ts +2 -0
- package/dist/types/src/cart/mutations.d.ts +6 -0
- package/dist/types/src/cart/store.d.ts +5 -2
- package/dist/types/src/cart.d.ts +2 -2
- package/dist/types/src/client.d.ts +53 -19
- package/dist/types/src/config.d.ts +6 -1
- package/dist/types/src/index.d.ts +2 -2
- package/dist/types/src/product-shape.d.ts +3 -1
- package/package.json +1 -2
- package/dist/esm/chunks/chunk-56FCLJJD.js +0 -1
- package/dist/esm/chunks/chunk-A5SYCVQM.js +0 -1
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +0 -1
- package/dist/esm/chunks/chunk-C5JYQCWH.js +0 -1
- package/dist/esm/chunks/extras-6POFBNOP.js +0 -1
- package/dist/esm/chunks/search-UJ73YBQ5.js +0 -1
- package/src/account/addresses-view.ts +0 -58
- package/src/account/answers.ts +0 -79
- package/src/account/markup.ts +0 -36
- package/src/account/operations.ts +0 -145
- package/src/account/order-lines.ts +0 -75
- package/src/account/order-list.ts +0 -107
- package/src/account/order-page.ts +0 -137
- package/src/account/page-answers.ts +0 -317
- package/src/account/page-events.ts +0 -253
- package/src/account/server-client.ts +0 -183
- package/src/account/subscriptions-view.ts +0 -115
- package/src/account-api.ts +0 -98
- package/src/account-marks.ts +0 -35
- package/src/account-pages.ts +0 -81
- package/src/account.ts +0 -284
- package/src/amounts.ts +0 -64
- package/src/analytics-shopify.ts +0 -246
- package/src/analytics.ts +0 -315
- package/src/api-version.ts +0 -79
- package/src/astro.ts +0 -272
- package/src/cart/cart-shape.ts +0 -25
- package/src/cart/checkout.ts +0 -101
- package/src/cart/drawer.ts +0 -125
- package/src/cart/mutations.ts +0 -237
- package/src/cart/render.ts +0 -266
- package/src/cart/store.ts +0 -279
- package/src/cart.ts +0 -345
- package/src/client.ts +0 -391
- package/src/collection.ts +0 -175
- package/src/config-sections.ts +0 -284
- package/src/config.ts +0 -480
- package/src/controls/cart-controls.ts +0 -123
- package/src/controls/discount.ts +0 -75
- package/src/controls/extras.ts +0 -54
- package/src/controls/navigation.ts +0 -77
- package/src/diagnose-markup.ts +0 -298
- package/src/diagnose.ts +0 -294
- package/src/dom.ts +0 -268
- package/src/extras.ts +0 -136
- package/src/fragments.ts +0 -261
- package/src/globals.d.ts +0 -15
- package/src/images.ts +0 -91
- package/src/index.ts +0 -415
- package/src/initial.ts +0 -101
- package/src/lazy-iife.ts +0 -197
- package/src/lazy.ts +0 -42
- package/src/loader.ts +0 -363
- package/src/market-codes.ts +0 -6
- package/src/markets-ui.ts +0 -227
- package/src/markets.ts +0 -246
- package/src/markup.ts +0 -127
- package/src/mounting.ts +0 -264
- package/src/page-market.ts +0 -110
- package/src/part-integrity.ts +0 -7
- package/src/parts/account-pages.ts +0 -7
- package/src/parts/account.ts +0 -7
- package/src/parts/analytics.ts +0 -7
- package/src/parts/diagnose.ts +0 -7
- package/src/parts/extras.ts +0 -7
- package/src/parts/markets.ts +0 -7
- package/src/parts/search.ts +0 -7
- package/src/presets/barba.ts +0 -194
- package/src/product-events.ts +0 -115
- package/src/product-render.ts +0 -218
- package/src/product-shape.ts +0 -49
- package/src/product-state.ts +0 -318
- package/src/product.ts +0 -308
- package/src/repeaters.ts +0 -161
- package/src/search.ts +0 -305
- package/src/selling-plans.ts +0 -145
- package/src/server.ts +0 -211
- package/src/shopify-values.ts +0 -91
- package/src/status.ts +0 -106
- package/src/storage.ts +0 -34
- package/src/store-html.ts +0 -359
- package/src/storefront/catalog.ts +0 -196
- package/src/storefront/json-ld.ts +0 -144
- package/src/variant-render.ts +0 -139
package/src/lazy-iife.ts
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `<script>` build: the optional parts are separate files next to tether(.min).js (tether.markets.min.js), loaded on
|
|
3
|
-
* first use and checked against the hashes the core was built with, from that folder or from `partsUrl`, which must
|
|
4
|
-
* serve the files of the same release. A part registers itself in `window.Tether.__parts` and uses the core modules in
|
|
5
|
-
* `window.Tether.__core` instead of bundling copies.
|
|
6
|
-
*/
|
|
7
|
-
import { cfg, isConfigured, log, MARKET_SOURCES, parseMarkets, reportError, warn } from './config.ts';
|
|
8
|
-
import { endpoint, gql, orNull, shaped, TetherError } from './client.ts';
|
|
9
|
-
import {
|
|
10
|
-
ANALYTICS_EVENTS,
|
|
11
|
-
attr,
|
|
12
|
-
attributesFromJson,
|
|
13
|
-
emit,
|
|
14
|
-
formatDate,
|
|
15
|
-
formatMoney,
|
|
16
|
-
get,
|
|
17
|
-
has,
|
|
18
|
-
hasTemplate,
|
|
19
|
-
isOwnEvent,
|
|
20
|
-
parseJSONAttr,
|
|
21
|
-
productUrl,
|
|
22
|
-
qsa,
|
|
23
|
-
repeat,
|
|
24
|
-
sel,
|
|
25
|
-
setImage,
|
|
26
|
-
setText,
|
|
27
|
-
show,
|
|
28
|
-
} from './dom.ts';
|
|
29
|
-
import { clearProductCache, getProduct } from './loader.ts';
|
|
30
|
-
import { allPlans } from './selling-plans.ts';
|
|
31
|
-
import { boundary, resolveProductKey } from './status.ts';
|
|
32
|
-
import { Cart } from './cart.ts';
|
|
33
|
-
import { scopes } from './product.ts';
|
|
34
|
-
import { listSelector } from './collection.ts';
|
|
35
|
-
import { liveLists, liveProducts, mountedProducts, mountProduct, pruneLive, within } from './mounting.ts';
|
|
36
|
-
import { toCountry, toLanguage } from './market-codes.ts';
|
|
37
|
-
import { isHttpsUrl } from './shopify-values.ts';
|
|
38
|
-
import { PART_INTEGRITY } from './part-integrity.ts';
|
|
39
|
-
import type { Parts, PartName, TetherInternals } from './lazy.ts';
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The core exports the parts use, shared instead of bundled twice, by the file name scripts/build.ts maps a part's
|
|
43
|
-
* imports to. Each file shares a list of values without their names: the build finds each value's export name in its
|
|
44
|
-
* module and hands a part's import of that name the value at its position. The core so carries no export names and no
|
|
45
|
-
* export a part doesn't use, and a part that imports one not listed here fails its build. A part reads its entries
|
|
46
|
-
* when it loads, once every module has run; read while the core runs, an import cycle through this file could leave
|
|
47
|
-
* a value unset.
|
|
48
|
-
*/
|
|
49
|
-
export const coreModules = {
|
|
50
|
-
get 'config.ts'() {
|
|
51
|
-
return [cfg, isConfigured, log, MARKET_SOURCES, parseMarkets, reportError, warn];
|
|
52
|
-
},
|
|
53
|
-
get 'client.ts'() {
|
|
54
|
-
return [endpoint, gql, orNull, shaped, TetherError];
|
|
55
|
-
},
|
|
56
|
-
get 'dom.ts'() {
|
|
57
|
-
return [
|
|
58
|
-
ANALYTICS_EVENTS,
|
|
59
|
-
attr,
|
|
60
|
-
attributesFromJson,
|
|
61
|
-
emit,
|
|
62
|
-
formatDate,
|
|
63
|
-
formatMoney,
|
|
64
|
-
get,
|
|
65
|
-
has,
|
|
66
|
-
hasTemplate,
|
|
67
|
-
isOwnEvent,
|
|
68
|
-
parseJSONAttr,
|
|
69
|
-
productUrl,
|
|
70
|
-
qsa,
|
|
71
|
-
repeat,
|
|
72
|
-
sel,
|
|
73
|
-
setImage,
|
|
74
|
-
setText,
|
|
75
|
-
show,
|
|
76
|
-
];
|
|
77
|
-
},
|
|
78
|
-
get 'loader.ts'() {
|
|
79
|
-
return [clearProductCache, getProduct];
|
|
80
|
-
},
|
|
81
|
-
get 'selling-plans.ts'() {
|
|
82
|
-
return [allPlans];
|
|
83
|
-
},
|
|
84
|
-
get 'status.ts'() {
|
|
85
|
-
return [boundary, resolveProductKey];
|
|
86
|
-
},
|
|
87
|
-
get 'cart.ts'() {
|
|
88
|
-
return [Cart];
|
|
89
|
-
},
|
|
90
|
-
get 'product.ts'() {
|
|
91
|
-
return [scopes];
|
|
92
|
-
},
|
|
93
|
-
get 'collection.ts'() {
|
|
94
|
-
return [listSelector];
|
|
95
|
-
},
|
|
96
|
-
get 'mounting.ts'() {
|
|
97
|
-
return [liveLists, liveProducts, mountedProducts, mountProduct, pruneLive, within];
|
|
98
|
-
},
|
|
99
|
-
get 'market-codes.ts'() {
|
|
100
|
-
return [toCountry, toLanguage];
|
|
101
|
-
},
|
|
102
|
-
get 'shopify-values.ts'() {
|
|
103
|
-
return [isHttpsUrl];
|
|
104
|
-
},
|
|
105
|
-
// Parts that load other parts (account → account-pages) use this same loader.
|
|
106
|
-
get 'lazy.ts'() {
|
|
107
|
-
return [loadPart];
|
|
108
|
-
},
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
const MINIFIED_SCRIPT = /\.min\.js(\?|$)/;
|
|
112
|
-
const coreScript = typeof document === 'undefined' ? '' : (document.currentScript?.getAttribute('src') ?? '');
|
|
113
|
-
const coreScriptUrl = coreScript ? new URL(coreScript, document.baseURI).href : '';
|
|
114
|
-
|
|
115
|
-
/** Parts loaded or loading. */
|
|
116
|
-
const parts = new Map<PartName, Promise<unknown>>();
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* The folder parts load from: the `partsUrl` (checked to be https or on this site when the config was resolved), else
|
|
120
|
-
* the core script's own folder. `null` when the core was not loaded by a `<script src>` (a module, a loader) and no
|
|
121
|
-
* `partsUrl` is set, so nothing says where parts are.
|
|
122
|
-
*/
|
|
123
|
-
function partsFolder(): string | null {
|
|
124
|
-
if (cfg.partsUrl) return new URL(cfg.partsUrl, document.baseURI).href;
|
|
125
|
-
if (!coreScriptUrl) return null;
|
|
126
|
-
return coreScriptUrl.replace(/[^/]*$/, '');
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Pins the script to the part file built with this core, wherever it loads from: a page that pins the core with
|
|
131
|
-
* `integrity` then runs only parts of that build, and a `partsUrl` must serve the same release's files.
|
|
132
|
-
*/
|
|
133
|
-
function applyIntegrity(script: HTMLScriptElement, integrity: string): void {
|
|
134
|
-
script.integrity = integrity;
|
|
135
|
-
// A cross-origin script is only checked when fetched with CORS (CDNs such as jsDelivr allow it).
|
|
136
|
-
if (new URL(script.src).origin !== window.location.origin) script.crossOrigin = 'anonymous';
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Loads a part's file. A failed load leaves the cache, so the next use tries again (the network may be back).
|
|
141
|
-
*
|
|
142
|
-
* @param integrity The file's hash from this core's build.
|
|
143
|
-
*/
|
|
144
|
-
function loadScript(name: PartName, folder: string, file: string, integrity: string): Promise<unknown> {
|
|
145
|
-
return new Promise((resolve, reject) => {
|
|
146
|
-
const url = `${folder.replace(/\/?$/, '/')}${file}`;
|
|
147
|
-
const script = document.createElement('script');
|
|
148
|
-
script.src = url;
|
|
149
|
-
script.async = true;
|
|
150
|
-
applyIntegrity(script, integrity);
|
|
151
|
-
script.onload = () => {
|
|
152
|
-
const { Tether } = window as { Tether?: TetherInternals };
|
|
153
|
-
const part = Tether?.__parts[name];
|
|
154
|
-
if (part) {
|
|
155
|
-
resolve(part);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
parts.delete(name);
|
|
159
|
-
reject(new Error(`${url} did not register`));
|
|
160
|
-
};
|
|
161
|
-
script.onerror = () => {
|
|
162
|
-
parts.delete(name);
|
|
163
|
-
reject(
|
|
164
|
-
new Error(
|
|
165
|
-
`Could not load ${url}, or it is not the file built with this Tether script. Host the same release's files ` +
|
|
166
|
-
'next to the Tether script or at partsUrl.',
|
|
167
|
-
),
|
|
168
|
-
);
|
|
169
|
-
};
|
|
170
|
-
document.head.appendChild(script);
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* @throws {Error} (rejected) When the part's file can't be loaded, fails its integrity check or doesn't register.
|
|
176
|
-
*/
|
|
177
|
-
export function loadPart<Name extends PartName>(name: Name): Promise<Parts[Name]> {
|
|
178
|
-
let loading = parts.get(name);
|
|
179
|
-
if (!loading) {
|
|
180
|
-
const folder = partsFolder();
|
|
181
|
-
if (folder === null) {
|
|
182
|
-
return Promise.reject(
|
|
183
|
-
new Error(`The Tether script's own location is unknown, so its ${name} part can't be found: set partsUrl.`),
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
const suffix = MINIFIED_SCRIPT.test(coreScriptUrl) || !coreScriptUrl ? '.min' : '';
|
|
187
|
-
const file = `tether.${name}${suffix}.js`;
|
|
188
|
-
const integrity = PART_INTEGRITY[file];
|
|
189
|
-
if (integrity === undefined) {
|
|
190
|
-
return Promise.reject(new Error(`This Tether script was built without ${file}, so it can't load that part.`));
|
|
191
|
-
}
|
|
192
|
-
loading = loadScript(name, folder, file, integrity);
|
|
193
|
-
parts.set(name, loading);
|
|
194
|
-
}
|
|
195
|
-
// A part's file is built from the module `Parts[Name]` names (scripts/build.ts), and registers that module.
|
|
196
|
-
return loading as Promise<Parts[Name]>;
|
|
197
|
-
}
|
package/src/lazy.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Optional parts that most pages never need, loaded on first use. In the ESM and Node builds they are dynamic
|
|
3
|
-
* imports, which bundlers split into their own chunks; the `<script>` build swaps this file for `lazy-iife.ts`
|
|
4
|
-
* (scripts/build.ts).
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
export interface Parts {
|
|
8
|
-
diagnose: typeof import('./diagnose.ts');
|
|
9
|
-
markets: typeof import('./markets-ui.ts');
|
|
10
|
-
search: typeof import('./search.ts');
|
|
11
|
-
extras: typeof import('./extras.ts');
|
|
12
|
-
account: typeof import('./account.ts');
|
|
13
|
-
'account-pages': typeof import('./account-pages.ts');
|
|
14
|
-
analytics: typeof import('./analytics.ts');
|
|
15
|
-
}
|
|
16
|
-
export type PartName = keyof Parts;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* What `window.Tether` carries for the `<script>` build's separately loaded parts, besides the API sites use. Left out
|
|
20
|
-
* of the published `Tether` type.
|
|
21
|
-
*/
|
|
22
|
-
export interface TetherInternals {
|
|
23
|
-
/** `coreModules`: the modules the parts use instead of bundling copies, in the `<script>` build only. */
|
|
24
|
-
__core: unknown;
|
|
25
|
-
/** Each part registers its module here when its file has loaded. */
|
|
26
|
-
__parts: Partial<Parts>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const PARTS: { [Name in PartName]: () => Promise<Parts[Name]> } = {
|
|
30
|
-
diagnose: () => import('./diagnose.ts'),
|
|
31
|
-
markets: () => import('./markets-ui.ts'),
|
|
32
|
-
search: () => import('./search.ts'),
|
|
33
|
-
extras: () => import('./extras.ts'),
|
|
34
|
-
account: () => import('./account.ts'),
|
|
35
|
-
'account-pages': () => import('./account-pages.ts'),
|
|
36
|
-
analytics: () => import('./analytics.ts'),
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
export const loadPart = <Name extends PartName>(name: Name): Promise<Parts[Name]> => PARTS[name]();
|
|
40
|
-
|
|
41
|
-
/** Only the `<script>` build shares its modules with separately loaded parts. */
|
|
42
|
-
export const coreModules = undefined;
|
package/src/loader.ts
DELETED
|
@@ -1,363 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Product data for the browser script. Products asked for in the same tick go out as one request, and every product
|
|
3
|
-
* is cached for the life of the page, which survives Barba/Swup page swaps. Products the server rendered
|
|
4
|
-
* (`tether/server` `hydrate()`) are used at once and refreshed once in the background.
|
|
5
|
-
*/
|
|
6
|
-
import { cfg, log } from './config.ts';
|
|
7
|
-
import { gql, matches, orNull, shaped, TetherError } from './client.ts';
|
|
8
|
-
import { productFragment, collectionFields } from './fragments.ts';
|
|
9
|
-
import { productKey } from './shopify-values.ts';
|
|
10
|
-
import { isRecord } from '../server/src/shared/json.ts';
|
|
11
|
-
import { PRODUCT, PRODUCT_CONNECTION, PRODUCT_NODE } from './product-shape.ts';
|
|
12
|
-
import type { Product, Image } from './fragments.ts';
|
|
13
|
-
import type { QueryText } from './client.ts';
|
|
14
|
-
import type { ProductConnection } from './product-shape.ts';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* A product request that missed is remembered this long, so one page asking twice sends one request, and a product
|
|
18
|
-
* published in Shopify meanwhile shows up on the next visit.
|
|
19
|
-
*/
|
|
20
|
-
const MISS_CACHE_MS = 10_000;
|
|
21
|
-
/** A product loaded live this recently is newer than a server-rendered copy of it, which is then ignored. */
|
|
22
|
-
const FRESH_MS = 60_000;
|
|
23
|
-
|
|
24
|
-
interface Waiter {
|
|
25
|
-
resolve: (product: Product) => void;
|
|
26
|
-
reject: (error: unknown) => void;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Handle or gid to the product, loaded or loading. */
|
|
30
|
-
const productCache = new Map<string, Promise<Product>>();
|
|
31
|
-
const waiting = new Map<string, Waiter>();
|
|
32
|
-
/** Keys of products that came from the server and weren't refreshed yet. */
|
|
33
|
-
const stale = new Set<string>();
|
|
34
|
-
/** Handle or gid to when it last loaded live, in ms since the epoch. */
|
|
35
|
-
const freshAt = new Map<string, number>();
|
|
36
|
-
/** Keys for the next batch. */
|
|
37
|
-
const pending: string[] = [];
|
|
38
|
-
let flushScheduled = false;
|
|
39
|
-
/**
|
|
40
|
-
* Bumped by `clearProductCache()`: responses to requests sent before that (in the old market, after a country
|
|
41
|
-
* switch) still reach their callers but never refill the cache.
|
|
42
|
-
*/
|
|
43
|
-
let generation = 0;
|
|
44
|
-
|
|
45
|
-
const isGid = (key: string) => key.startsWith('gid://');
|
|
46
|
-
|
|
47
|
-
/** Accepts a handle, numeric id, gid or a full product URL. */
|
|
48
|
-
export const normalizeProductKey = productKey;
|
|
49
|
-
|
|
50
|
-
/** Puts a product the page already has (from a collection query) in the cache, under its handle and its id. */
|
|
51
|
-
export function primeProduct(product: Product | null | undefined): void {
|
|
52
|
-
if (!product) return;
|
|
53
|
-
const loaded = Promise.resolve(product);
|
|
54
|
-
productCache.set(product.handle, loaded);
|
|
55
|
-
productCache.set(product.id, loaded);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* @param rawKey A handle, numeric id, gid or product URL.
|
|
60
|
-
* @throws {TetherError} `NOT_FOUND` (rejected) for an empty key or a product the store doesn't have.
|
|
61
|
-
*/
|
|
62
|
-
export function getProduct(rawKey: string | number | null | undefined): Promise<Product> {
|
|
63
|
-
const key = normalizeProductKey(rawKey);
|
|
64
|
-
if (!key) return Promise.reject(new TetherError('empty product key', { code: 'NOT_FOUND' }));
|
|
65
|
-
const cached = productCache.get(key);
|
|
66
|
-
if (cached) return cached;
|
|
67
|
-
|
|
68
|
-
const loading = new Promise<Product>((resolve, reject) => {
|
|
69
|
-
waiting.set(key, { resolve, reject });
|
|
70
|
-
pending.push(key);
|
|
71
|
-
if (!flushScheduled) {
|
|
72
|
-
flushScheduled = true;
|
|
73
|
-
setTimeout(() => void flush(), 0);
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
productCache.set(key, loading);
|
|
77
|
-
return loading;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* @param operation An operation that spreads `...TetherProduct`.
|
|
82
|
-
* @returns The operation with the product fragment (which starts on its own line), written for what the token may
|
|
83
|
-
* read.
|
|
84
|
-
*/
|
|
85
|
-
const withProductFragment =
|
|
86
|
-
(operation: string): QueryText =>
|
|
87
|
-
(access) =>
|
|
88
|
-
`${operation}${productFragment(cfg, access)}`;
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* @param keys Handles and gids.
|
|
92
|
-
* @returns One query for all of them: gids through `nodes(ids:)` as `byId`, each handle as its own alias `h<index>`.
|
|
93
|
-
*/
|
|
94
|
-
function batchQuery(keys: string[]): { query: QueryText; variables: Record<string, unknown>; handles: string[] } {
|
|
95
|
-
const ids = keys.filter(isGid);
|
|
96
|
-
const handles = keys.filter((key) => !isGid(key));
|
|
97
|
-
const definitions: string[] = [];
|
|
98
|
-
const selections: string[] = [];
|
|
99
|
-
const variables: Record<string, unknown> = {};
|
|
100
|
-
if (ids.length) {
|
|
101
|
-
definitions.push('$ids: [ID!]!');
|
|
102
|
-
variables['ids'] = ids;
|
|
103
|
-
selections.push('byId: nodes(ids: $ids) { ... on Product { ...TetherProduct } }');
|
|
104
|
-
}
|
|
105
|
-
handles.forEach((handle, index) => {
|
|
106
|
-
definitions.push(`$h${index}: String!`);
|
|
107
|
-
variables[`h${index}`] = handle;
|
|
108
|
-
selections.push(`h${index}: product(handle: $h${index}) { ...TetherProduct }`);
|
|
109
|
-
});
|
|
110
|
-
const query = withProductFragment(`query TetherProducts(${definitions.join(',')}) { ${selections.join(' ')} }`);
|
|
111
|
-
return { query, variables, handles };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function settleWaiting(key: string, settle: (waiter: Waiter) => void): void {
|
|
115
|
-
const waiter = waiting.get(key);
|
|
116
|
-
waiting.delete(key);
|
|
117
|
-
if (waiter) settle(waiter);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function forgetMissLater(key: string, batchGeneration: number): void {
|
|
121
|
-
if (batchGeneration !== generation) return;
|
|
122
|
-
const miss = productCache.get(key);
|
|
123
|
-
setTimeout(() => {
|
|
124
|
-
if (productCache.get(key) === miss) productCache.delete(key);
|
|
125
|
-
}, MISS_CACHE_MS);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** A node of a type other than Product, for which `... on Product` selects no field. */
|
|
129
|
-
interface OtherNode {
|
|
130
|
-
id?: undefined;
|
|
131
|
-
}
|
|
132
|
-
/** The answer to `batchQuery`: `byId` for the gids, `h<index>` for each handle. */
|
|
133
|
-
type BatchData = Partial<Record<`h${number}`, Product | null>> & { byId?: (Product | OtherNode | null)[] };
|
|
134
|
-
|
|
135
|
-
function isBatchData(data: unknown): data is BatchData {
|
|
136
|
-
if (!isRecord(data)) return false;
|
|
137
|
-
const { byId = [], ...byHandle } = data;
|
|
138
|
-
return matches(byId, [orNull(PRODUCT_NODE)]) && Object.values(byHandle).every(orNull(PRODUCT));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async function flush() {
|
|
142
|
-
flushScheduled = false;
|
|
143
|
-
const batchGeneration = generation;
|
|
144
|
-
const keys = pending.splice(0);
|
|
145
|
-
if (!keys.length) return;
|
|
146
|
-
const { query, variables, handles } = batchQuery(keys);
|
|
147
|
-
log('fetch products', keys);
|
|
148
|
-
|
|
149
|
-
let data;
|
|
150
|
-
try {
|
|
151
|
-
data = await gql(query, variables, isBatchData);
|
|
152
|
-
} catch (error) {
|
|
153
|
-
keys.forEach((key) => {
|
|
154
|
-
settleWaiting(key, (waiter) => {
|
|
155
|
-
if (batchGeneration === generation) productCache.delete(key);
|
|
156
|
-
waiter.reject(error);
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const found = new Map<string, Product>();
|
|
163
|
-
for (const product of data.byId ?? []) if (product?.id) found.set(product.id, product);
|
|
164
|
-
handles.forEach((handle, index) => {
|
|
165
|
-
const product = data[`h${index}`];
|
|
166
|
-
if (product) found.set(handle, product);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
const now = Date.now();
|
|
170
|
-
keys.forEach((key) => {
|
|
171
|
-
settleWaiting(key, (waiter) => {
|
|
172
|
-
const product = found.get(key);
|
|
173
|
-
if (!product) {
|
|
174
|
-
forgetMissLater(key, batchGeneration);
|
|
175
|
-
waiter.reject(new TetherError(`Product not found: ${key}`, { code: 'NOT_FOUND' }));
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
if (batchGeneration === generation) {
|
|
179
|
-
primeProduct(product);
|
|
180
|
-
freshAt.set(product.handle, now);
|
|
181
|
-
freshAt.set(product.id, now);
|
|
182
|
-
}
|
|
183
|
-
waiter.resolve(product);
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Forgets every product, for a new market: requests already sent no longer fill the cache. */
|
|
189
|
-
export function clearProductCache(): void {
|
|
190
|
-
productCache.clear();
|
|
191
|
-
generation += 1;
|
|
192
|
-
stale.clear();
|
|
193
|
-
freshAt.clear();
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Uses products the server rendered, unless the page loaded the same product live in the last `FRESH_MS` (an earlier
|
|
198
|
-
* page of a View Transition visit), whose copy is newer.
|
|
199
|
-
*/
|
|
200
|
-
export function hydrateProducts(products: readonly Product[] | null | undefined): void {
|
|
201
|
-
for (const product of products ?? []) {
|
|
202
|
-
if (!product.id || !product.handle) continue;
|
|
203
|
-
const loadedAgo = Date.now() - (freshAt.get(product.handle) ?? 0);
|
|
204
|
-
if (loadedAgo < FRESH_MS && productCache.has(product.handle)) continue;
|
|
205
|
-
primeProduct(product);
|
|
206
|
-
stale.add(product.handle);
|
|
207
|
-
stale.add(product.id);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** @returns True once per server-rendered product: the caller then refreshes it. */
|
|
212
|
-
export function takeStale(rawKey: string | number | null | undefined): boolean {
|
|
213
|
-
const key = normalizeProductKey(rawKey);
|
|
214
|
-
if (!key || !stale.has(key)) return false;
|
|
215
|
-
stale.delete(key);
|
|
216
|
-
// The handle and the id point to the same product: one refresh covers both.
|
|
217
|
-
void productCache.get(key)?.then((product) => {
|
|
218
|
-
stale.delete(product.handle);
|
|
219
|
-
stale.delete(product.id);
|
|
220
|
-
}, failureIsReportedByTheRender);
|
|
221
|
-
return true;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* The cached promise is the one the product's render awaits, and the render reports its failure: the stale cleanup
|
|
226
|
-
* above has nothing left to do for a product that did not load.
|
|
227
|
-
*/
|
|
228
|
-
function failureIsReportedByTheRender() {
|
|
229
|
-
// Nothing to clean: see above.
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/** Loads this product again, past the cache. Products refreshed in the same tick go out as one request. */
|
|
233
|
-
export function refreshProduct(key: string | number): Promise<Product> {
|
|
234
|
-
const normalized = normalizeProductKey(key);
|
|
235
|
-
if (normalized) productCache.delete(normalized);
|
|
236
|
-
return getProduct(key);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
export interface PageInfo {
|
|
240
|
-
hasNextPage: boolean;
|
|
241
|
-
endCursor: string | null;
|
|
242
|
-
}
|
|
243
|
-
export interface Collection {
|
|
244
|
-
id: string;
|
|
245
|
-
handle: string;
|
|
246
|
-
title: string;
|
|
247
|
-
description: string;
|
|
248
|
-
descriptionHtml: string;
|
|
249
|
-
image: Image | null;
|
|
250
|
-
seo: { title: string | null; description: string | null };
|
|
251
|
-
}
|
|
252
|
-
export interface ProductPage {
|
|
253
|
-
collection: Collection | null;
|
|
254
|
-
products: Product[];
|
|
255
|
-
pageInfo: PageInfo;
|
|
256
|
-
}
|
|
257
|
-
export interface ProductQuery {
|
|
258
|
-
/** A collection handle; without one, a search. */
|
|
259
|
-
collection?: string;
|
|
260
|
-
/** Storefront search syntax, for a search. */
|
|
261
|
-
query?: string;
|
|
262
|
-
first?: number;
|
|
263
|
-
/** The cursor of the page before. */
|
|
264
|
-
after?: string | null;
|
|
265
|
-
/** `ProductCollectionSortKeys` for a collection, `ProductSortKeys` for a search. */
|
|
266
|
-
sortKey?: string;
|
|
267
|
-
reverse?: boolean;
|
|
268
|
-
/** `ProductFilter` values, for a collection. */
|
|
269
|
-
filters?: unknown[];
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** A collection's answer: `null` for a handle the store doesn't have. Its other fields are written as text. */
|
|
273
|
-
export const isCollectionAnswer = shaped<{ collection: (Collection & { products: ProductConnection }) | null }>({
|
|
274
|
-
collection: orNull({ handle: 'string', products: PRODUCT_CONNECTION }),
|
|
275
|
-
});
|
|
276
|
-
export const isSearchAnswer = shaped<{ products: ProductConnection }>({ products: PRODUCT_CONNECTION });
|
|
277
|
-
export const isRecommendationsAnswer = shaped<{ productRecommendations: Product[] | null }>({
|
|
278
|
-
productRecommendations: orNull([PRODUCT]),
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
const COLLECTION_QUERY = `query TetherCollection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
|
|
282
|
-
collection(handle: $handle) {
|
|
283
|
-
${collectionFields()}
|
|
284
|
-
products(first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
|
|
285
|
-
nodes { ...TetherProduct }
|
|
286
|
-
pageInfo { hasNextPage endCursor }
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}`;
|
|
290
|
-
|
|
291
|
-
const SEARCH_QUERY = `query TetherSearch($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
|
|
292
|
-
products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
|
|
293
|
-
nodes { ...TetherProduct }
|
|
294
|
-
pageInfo { hasNextPage endCursor }
|
|
295
|
-
}
|
|
296
|
-
}`;
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* A page of products from a collection (by handle) or from a search. Every product on it is cached.
|
|
300
|
-
*
|
|
301
|
-
* @throws {TetherError} `NOT_FOUND` for a collection the store doesn't have.
|
|
302
|
-
*/
|
|
303
|
-
export async function getProducts({
|
|
304
|
-
collection,
|
|
305
|
-
query,
|
|
306
|
-
first = cfg.collectionFirst,
|
|
307
|
-
after = null,
|
|
308
|
-
sortKey,
|
|
309
|
-
reverse = false,
|
|
310
|
-
filters,
|
|
311
|
-
}: ProductQuery = {}): Promise<ProductPage> {
|
|
312
|
-
if (collection) {
|
|
313
|
-
const data = await gql(
|
|
314
|
-
withProductFragment(COLLECTION_QUERY),
|
|
315
|
-
{
|
|
316
|
-
handle: collection,
|
|
317
|
-
first,
|
|
318
|
-
after,
|
|
319
|
-
sortKey: sortKey || undefined,
|
|
320
|
-
reverse,
|
|
321
|
-
filters: filters ?? undefined,
|
|
322
|
-
},
|
|
323
|
-
isCollectionAnswer,
|
|
324
|
-
);
|
|
325
|
-
if (!data.collection) throw new TetherError(`Collection not found: ${collection}`, { code: 'NOT_FOUND' });
|
|
326
|
-
const { products, ...rest } = data.collection;
|
|
327
|
-
products.nodes.forEach(primeProduct);
|
|
328
|
-
return { collection: rest, products: products.nodes, pageInfo: products.pageInfo };
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const data = await gql(
|
|
332
|
-
withProductFragment(SEARCH_QUERY),
|
|
333
|
-
{ first, after, query: query || undefined, sortKey: sortKey || undefined, reverse },
|
|
334
|
-
isSearchAnswer,
|
|
335
|
-
);
|
|
336
|
-
data.products.nodes.forEach(primeProduct);
|
|
337
|
-
return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Shopify's recommendations for a product. Every product in them is cached.
|
|
342
|
-
*
|
|
343
|
-
* @param product A product, or its handle, id or gid.
|
|
344
|
-
*/
|
|
345
|
-
export async function getRecommendations(
|
|
346
|
-
product: string | number | { id: string } | null | undefined,
|
|
347
|
-
intent: 'RELATED' | 'COMPLEMENTARY' = 'RELATED',
|
|
348
|
-
): Promise<Product[]> {
|
|
349
|
-
const key = normalizeProductKey(typeof product === 'object' ? product?.id : product);
|
|
350
|
-
const byId = key?.startsWith('gid://') ?? false;
|
|
351
|
-
const data = await gql(
|
|
352
|
-
withProductFragment(
|
|
353
|
-
`query TetherRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
|
|
354
|
-
productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
|
|
355
|
-
}`,
|
|
356
|
-
),
|
|
357
|
-
byId ? { id: key, intent } : { handle: key, intent },
|
|
358
|
-
isRecommendationsAnswer,
|
|
359
|
-
);
|
|
360
|
-
const list = data.productRecommendations ?? [];
|
|
361
|
-
list.forEach(primeProduct);
|
|
362
|
-
return list;
|
|
363
|
-
}
|
package/src/market-codes.ts
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Country and language codes as Shopify's `@inContext` takes them, parsed by the module the Worker uses for the
|
|
3
|
-
* login's market, so the browser and the Worker accept and refuse the same values.
|
|
4
|
-
*/
|
|
5
|
-
export { toCountry, toLanguage } from '../server/src/shared/market-codes.ts';
|
|
6
|
-
export type { CountryCode, LanguageCode } from '../server/src/shared/market-codes.ts';
|