@occluze/tether 0.6.7 → 0.6.8

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.
Files changed (135) hide show
  1. package/README.md +3 -3
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
  4. package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
  5. package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
  6. package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
  7. package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
  8. package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
  9. package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
  10. package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
  11. package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
  12. package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
  13. package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
  14. package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
  15. package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
  16. package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
  17. package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
  18. package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
  19. package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
  20. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
  21. package/dist/esm/chunks/search-OU75RNQL.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -1
  24. package/dist/presets/barba.esm.js +56 -36
  25. package/dist/tether.account-pages.js +773 -442
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +648 -318
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +318 -191
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +331 -242
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +78 -33
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3176 -2015
  36. package/dist/tether.markets.js +231 -126
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +221 -126
  41. package/dist/tether.search.min.js +1 -1
  42. package/package.json +23 -4
  43. package/src/account/addresses-view.js +92 -0
  44. package/src/account/browser-session.js +359 -0
  45. package/src/account/markup.js +48 -0
  46. package/src/account/operations.js +180 -0
  47. package/src/account/order-lines.js +96 -0
  48. package/src/account/order-list.js +91 -0
  49. package/src/account/order-page.js +192 -0
  50. package/src/account/page-events.js +288 -0
  51. package/src/account/server-client.js +172 -0
  52. package/src/account/shared.js +111 -0
  53. package/src/account/shopify-auth.js +150 -0
  54. package/src/account/subscriptions-view.js +158 -0
  55. package/src/account-api.js +118 -0
  56. package/src/account-links.js +35 -0
  57. package/src/account-pages.js +69 -298
  58. package/src/account.js +280 -323
  59. package/src/amounts.js +36 -0
  60. package/src/analytics-shopify.js +241 -0
  61. package/src/analytics.js +304 -184
  62. package/src/api-version.js +75 -25
  63. package/src/astro.js +233 -68
  64. package/src/cart/checkout.js +97 -0
  65. package/src/cart/drawer.js +138 -0
  66. package/src/cart/mutations.js +234 -0
  67. package/src/cart/render.js +294 -0
  68. package/src/cart/store.js +326 -0
  69. package/src/cart.js +320 -488
  70. package/src/client.js +214 -78
  71. package/src/collection.js +126 -57
  72. package/src/config.js +323 -59
  73. package/src/controls/cart-controls.js +155 -0
  74. package/src/controls/discount.js +96 -0
  75. package/src/controls/extras.js +63 -0
  76. package/src/controls/navigation.js +83 -0
  77. package/src/diagnose-markup.js +333 -0
  78. package/src/diagnose.js +251 -202
  79. package/src/dom.js +219 -220
  80. package/src/extras.js +119 -37
  81. package/src/fragments.js +115 -22
  82. package/src/images.js +104 -0
  83. package/src/index.js +245 -287
  84. package/src/initial.js +78 -66
  85. package/src/lazy-iife.js +92 -22
  86. package/src/lazy.js +25 -4
  87. package/src/loader.js +263 -124
  88. package/src/market-codes.js +35 -0
  89. package/src/markets-ui.js +169 -55
  90. package/src/markets.js +286 -85
  91. package/src/markup.js +64 -0
  92. package/src/mounting.js +214 -91
  93. package/src/page-market.js +116 -0
  94. package/src/parts/account-pages.js +4 -1
  95. package/src/parts/account.js +4 -1
  96. package/src/parts/analytics.js +4 -1
  97. package/src/parts/diagnose.js +4 -1
  98. package/src/parts/extras.js +4 -1
  99. package/src/parts/markets.js +4 -1
  100. package/src/parts/search.js +4 -1
  101. package/src/presets/barba.js +120 -30
  102. package/src/product-events.js +130 -0
  103. package/src/product-render.js +257 -0
  104. package/src/product-state.js +318 -0
  105. package/src/product.js +233 -459
  106. package/src/repeaters.js +173 -0
  107. package/src/search.js +277 -107
  108. package/src/selling-plans.js +107 -47
  109. package/src/server.js +168 -205
  110. package/src/shopify-values.js +89 -0
  111. package/src/status.js +99 -39
  112. package/src/store-html.js +0 -0
  113. package/src/storefront/catalog.js +199 -0
  114. package/src/storefront/json-ld.js +167 -0
  115. package/src/variant-render.js +165 -0
  116. package/types/astro.d.ts +13 -2
  117. package/types/index.d.ts +178 -27
  118. package/types/server.d.ts +89 -17
  119. package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
  120. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  121. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  122. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  123. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  124. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  125. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  126. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  127. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  128. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  129. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  130. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  131. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  132. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  133. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  134. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/markets.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Markets: which country/language prices, currency and translations follow.
2
+ * Markets: which country and language prices, currency and translations follow.
3
3
  *
4
4
  * markets: {
5
5
  * detect: true, // or a list: ['saved', 'server', 'webflow']
@@ -7,7 +7,7 @@
7
7
  * de: { country: 'DE', language: 'DE', locale: 'de-DE' },
8
8
  * 'fr-be': { country: 'BE', language: 'FR' },
9
9
  * },
10
- * cookie: 'tether_country', // a cookie your server/Worker sets (e.g. from cf-ipcountry)
10
+ * cookie: 'tether_country', // a cookie your server or Worker sets (from cf-ipcountry)
11
11
  * remember: true, // keep the visitor's tether-country-select choice
12
12
  * }
13
13
  *
@@ -15,119 +15,320 @@
15
15
  * saved the visitor's own choice (localStorage), from tether-country-select / Tether.setCountry()
16
16
  * server window.TETHER_COUNTRY, <meta name="tether-country" content="DE">, or the cookie
17
17
  * webflow locale subdirectory (/de/...) or <html lang="de-DE">, through `locales`
18
- * then the static `country` / `language` config. Without `detect`, only `saved` applies
19
- * (so a country select keeps working across pages); `detect: false` turns even that off. Tether never calls an IP lookup service:
20
- * a country from the visitor's IP only comes from your own server (see README).
18
+ * then the static `country` / `language` config. Without `detect`, only `saved` applies, so a country select keeps
19
+ * working across pages; `detect: false` turns even that off. Tether never calls an IP lookup service: a country from
20
+ * the visitor's IP only comes from the site's own server (see README).
21
21
  */
22
22
  import { cfg } from './config.js';
23
23
  import { gql } from './client.js';
24
+ import { toCountry, toLanguage } from './market-codes.js';
25
+ /** @import { MarketsConfig } from './config.js' */
26
+ /** @import { CountryCode, LanguageCode } from './market-codes.js' */
24
27
 
25
- const COUNTRY = /^[A-Z]{2}$/;
26
- // Shopify LanguageCode values with a region; others are the plain ISO 639-1 code.
27
- const REGIONAL_LANGUAGES = new Set(['PT_BR', 'PT_PT', 'ZH_CN', 'ZH_TW']);
28
+ /**
29
+ * @typedef {object} Market A market a visitor chose, as `setCountry()` stores it.
30
+ * @property {CountryCode} country
31
+ * @property {LanguageCode | undefined} language
32
+ * @property {string | undefined} locale An `Intl` locale for money and dates.
33
+ */
34
+
35
+ /** @typedef {'saved' | 'server' | 'webflow'} MarketSource */
36
+ /** @typedef {'country' | 'language' | 'locale'} MarketField */
37
+ /** @typedef {Partial<Record<MarketField, MarketSource>>} MarketSources */
38
+
39
+ /**
40
+ * @typedef {object} DetectedMarket The fields detection found, and the source each came from.
41
+ * @property {CountryCode} [country]
42
+ * @property {LanguageCode} [language]
43
+ * @property {string} [locale]
44
+ * @property {MarketSources} [sources] Absent when `detect: false` turns detection off.
45
+ */
46
+
47
+ /**
48
+ * @typedef {object} MarketEnv What detection reads, passed in so it runs on any document.
49
+ * @property {Pick<Document, 'cookie' | 'querySelector' | 'documentElement'> | null} document
50
+ * @property {Pick<Location, 'pathname'> | null} location
51
+ * @property {Pick<Storage, 'getItem'> | null} storage
52
+ * @property {object} global Where `TETHER_COUNTRY` and `TETHER_LANGUAGE` are read from (`window`).
53
+ */
54
+
55
+ /** @typedef {{ country?: unknown, language?: unknown, locale?: unknown }} MarketHint A source's raw values. */
28
56
 
57
+ /** @typedef {{ isoCode: string, name: string }} NamedCode */
58
+ /** @typedef {{ isoCode: string, symbol: string }} Currency */
59
+ /** @typedef {NamedCode & { currency: Currency }} LocalizedCountry */
60
+ /**
61
+ * @typedef {object} Localization The store's markets, from the Storefront API's `localization`.
62
+ * @property {LocalizedCountry} country The country of the current `@inContext`.
63
+ * @property {NamedCode} language
64
+ * @property {(LocalizedCountry & { availableLanguages: NamedCode[] })[]} availableCountries
65
+ */
66
+
67
+ /** @type {readonly MarketSource[]} */
68
+ const ALL_SOURCES = ['saved', 'server', 'webflow'];
69
+ const DEFAULT_COOKIE = 'tether_country';
70
+
71
+ /** @returns {string} The localStorage key of the visitor's market: `<prefix>:market:<domain>`. */
29
72
  export const storageKey = () => `${cfg.prefix}:market:${cfg.domain}`;
30
73
 
31
- const defaultEnv = () => ({
32
- document: typeof document !== 'undefined' ? document : null,
33
- location: typeof window !== 'undefined' ? window.location : null,
34
- storage: (() => { try { return window.localStorage; } catch { return null; } })(),
35
- global: typeof window !== 'undefined' ? window : {},
36
- });
74
+ /**
75
+ * Accessing `localStorage` throws where the browser blocks storage (sandboxed frames, cookies turned off); the visitor
76
+ * then simply has no saved market.
77
+ *
78
+ * @returns {Storage | null}
79
+ */
80
+ function localStorageIfAllowed() {
81
+ if (typeof window === 'undefined') return null;
82
+ try {
83
+ return window.localStorage;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /** @returns {MarketEnv} The page this script runs on. */
90
+ function pageEnv() {
91
+ const hasWindow = typeof window !== 'undefined';
92
+ return {
93
+ document: typeof document !== 'undefined' ? document : null,
94
+ location: hasWindow ? window.location : null,
95
+ storage: localStorageIfAllowed(),
96
+ global: hasWindow ? window : {},
97
+ };
98
+ }
99
+
100
+ /**
101
+ * @param {string} text
102
+ * @returns {unknown} The JSON value, `null` for text that isn't JSON (a key another script wrote).
103
+ */
104
+ function parseJsonOrNull(text) {
105
+ try {
106
+ return /** @type {unknown} */ (JSON.parse(text));
107
+ } catch (error) {
108
+ if (error instanceof SyntaxError) return null;
109
+ throw error;
110
+ }
111
+ }
37
112
 
38
- export function toCountry(v) {
39
- const c = String(v || '').trim().toUpperCase();
40
- return COUNTRY.test(c) && c !== 'XX' && c !== 'T1' ? c : null; // XX/T1: Cloudflare's unknown/Tor
113
+ /**
114
+ * @param {unknown} value
115
+ * @returns {Market | null} `value` as a market, when it has a valid country. A language or locale that isn't valid is
116
+ * left out.
117
+ */
118
+ function toMarket(value) {
119
+ if (typeof value !== 'object' || value === null) return null;
120
+ const country = toCountry(Reflect.get(value, 'country'));
121
+ if (!country) return null;
122
+ const locale = /** @type {unknown} */ (Reflect.get(value, 'locale'));
123
+ return {
124
+ country,
125
+ language: toLanguage(Reflect.get(value, 'language')) ?? undefined,
126
+ locale: typeof locale === 'string' && locale ? locale : undefined,
127
+ };
41
128
  }
42
129
 
43
- /** 'de-DE' -> 'DE', 'pt-BR' -> 'PT_BR', 'en' -> 'EN'. */
44
- export function toLanguage(v) {
45
- const raw = String(v || '').trim().replace('-', '_').toUpperCase();
46
- if (!raw) return null;
47
- if (REGIONAL_LANGUAGES.has(raw)) return raw;
48
- const primary = raw.split('_')[0];
49
- return /^[A-Z]{2,3}$/.test(primary) ? primary : null;
130
+ /**
131
+ * The visitor's saved market (`setCountry()`, `tether-country-select`), validated: storage is written by other tabs,
132
+ * older versions and other scripts too.
133
+ *
134
+ * @param {Pick<Storage, 'getItem'> | null} [storage] Default `localStorage`, `null` where the browser blocks it.
135
+ * @returns {Market | null} `null` when nothing valid is saved.
136
+ */
137
+ export function readSavedMarket(storage = localStorageIfAllowed()) {
138
+ const text = storage?.getItem(storageKey());
139
+ return text ? toMarket(parseJsonOrNull(text)) : null;
50
140
  }
51
141
 
142
+ /**
143
+ * @param {Pick<Document, 'cookie'> | null} doc
144
+ * @param {string} name
145
+ * @returns {string | null}
146
+ */
52
147
  function readCookie(doc, name) {
53
148
  if (!doc || !name) return null;
54
- const hit = String(doc.cookie || '').split(';').map((c) => c.trim()).find((c) => c.startsWith(`${name}=`));
55
- return hit ? decodeURIComponent(hit.slice(name.length + 1)) : null;
149
+ const cookie = doc.cookie
150
+ .split(';')
151
+ .map((part) => part.trim())
152
+ .find((part) => part.startsWith(`${name}=`));
153
+ return cookie ? decodeURIComponent(cookie.slice(name.length + 1)) : null;
56
154
  }
57
155
 
58
- const lookup = (locales, key) => {
156
+ /**
157
+ * @param {MarketsConfig['locales']} locales
158
+ * @param {string | undefined} key
159
+ * @returns {MarketHint | null} The entry for `key`, ignoring case.
160
+ */
161
+ function lookupLocale(locales, key) {
59
162
  if (!locales || !key) return null;
60
- const k = String(key).toLowerCase();
61
- const found = Object.keys(locales).find((x) => x.toLowerCase() === k);
62
- return found ? locales[found] : null;
63
- };
64
-
65
- const readers = {
66
- saved(m, env) {
67
- if (m.remember === false || !env.storage) return null;
68
- try { return JSON.parse(env.storage.getItem(storageKey()) || 'null'); } catch { return null; }
69
- },
70
- server(m, env) {
71
- const meta = (name) => env.document?.querySelector?.(`meta[name="${name}"]`)?.getAttribute('content');
72
- const country = env.global.TETHER_COUNTRY || meta('tether-country') || readCookie(env.document, m.cookie || 'tether_country');
73
- const language = env.global.TETHER_LANGUAGE || meta('tether-language');
74
- return { country, language };
75
- },
76
- webflow(m, env) {
77
- const segment = (env.location?.pathname || '/').split('/').filter(Boolean)[0];
78
- const fromPath = lookup(m.locales, segment);
79
- if (fromPath) return fromPath;
80
- const lang = env.document?.documentElement?.getAttribute('lang');
81
- if (!lang) return null;
82
- const mapped = lookup(m.locales, lang) || lookup(m.locales, lang.split('-')[0]);
83
- if (mapped) return { locale: lang, ...mapped };
84
- if (m.locales && m.strict) return null;
85
- // Unmapped: language from <html lang>, country only from a region subtag (de-CH -> CH).
86
- const region = lang.split('-')[1];
87
- return { language: lang, country: region && region.length === 2 ? region : null, locale: lang };
88
- },
89
- };
90
-
91
- /**
92
- * Returns { country, language, locale, sources } with only the fields found.
93
- * Pure apart from reading the DOM/storage: easy to test.
94
- */
95
- export function detectMarket(markets = cfg.markets, env = defaultEnv()) {
96
- const m = markets || {};
97
- if (m.detect === false) return {};
98
- // Without detect, only the visitor's own choice (from a country select) is applied.
99
- const list = !m.detect ? ['saved'] : m.detect === true ? ['saved', 'server', 'webflow'] : [].concat(m.detect);
100
- markets = m;
101
- const out = { sources: {} };
102
- list.forEach((name) => {
103
- const r = readers[name]?.(markets, env);
104
- if (!r) return;
105
- const vals = { country: toCountry(r.country), language: toLanguage(r.language), locale: r.locale || null };
106
- Object.entries(vals).forEach(([k, v]) => {
107
- if (v && !out[k]) { out[k] = v; out.sources[k] = name; }
108
- });
109
- });
110
- return out;
163
+ const wanted = key.toLowerCase();
164
+ const found = Object.keys(locales).find((name) => name.toLowerCase() === wanted);
165
+ return found ? (locales[found] ?? null) : null;
166
+ }
167
+
168
+ /**
169
+ * @param {MarketsConfig} markets
170
+ * @param {MarketEnv} env
171
+ * @returns {MarketHint}
172
+ */
173
+ function readServerHint(markets, env) {
174
+ /** @param {string} name */
175
+ const meta = (name) => env.document?.querySelector(`meta[name="${name}"]`)?.getAttribute('content');
176
+ return {
177
+ country:
178
+ Reflect.get(env.global, 'TETHER_COUNTRY') ||
179
+ meta('tether-country') ||
180
+ readCookie(env.document, markets.cookie || DEFAULT_COOKIE),
181
+ language: Reflect.get(env.global, 'TETHER_LANGUAGE') || meta('tether-language'),
182
+ };
183
+ }
184
+
185
+ /**
186
+ * @param {MarketsConfig} markets
187
+ * @param {MarketEnv} env
188
+ * @returns {MarketHint | null}
189
+ */
190
+ function readWebflowLocale(markets, env) {
191
+ const segment = (env.location?.pathname ?? '/').split('/').find((part) => part !== '');
192
+ const fromPath = lookupLocale(markets.locales, segment);
193
+ if (fromPath) return fromPath;
194
+ const lang = env.document?.documentElement.getAttribute('lang');
195
+ if (!lang) return null;
196
+ const mapped = lookupLocale(markets.locales, lang) ?? lookupLocale(markets.locales, lang.split('-')[0]);
197
+ if (mapped) return { locale: lang, ...mapped };
198
+ if (markets.locales && markets.strict) return null;
199
+ // Unmapped: the language from <html lang>, a country only from a region subtag (de-CH -> CH).
200
+ const region = lang.split('-')[1];
201
+ return { language: lang, country: region?.length === 2 ? region : null, locale: lang };
202
+ }
203
+
204
+ /**
205
+ * @param {MarketSource} source
206
+ * @param {MarketsConfig} markets
207
+ * @param {MarketEnv} env
208
+ * @returns {MarketHint | null}
209
+ */
210
+ function readSource(source, markets, env) {
211
+ switch (source) {
212
+ case 'saved':
213
+ return markets.remember === false ? null : readSavedMarket(env.storage);
214
+ case 'server':
215
+ return readServerHint(markets, env);
216
+ case 'webflow':
217
+ return readWebflowLocale(markets, env);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * @param {unknown} name
223
+ * @returns {name is MarketSource}
224
+ */
225
+ const isMarketSource = (name) => ALL_SOURCES.some((source) => source === name);
226
+
227
+ /**
228
+ * @param {MarketsConfig['detect']} detect
229
+ * @returns {readonly MarketSource[]} Without `detect`, only the visitor's own choice from a country select.
230
+ */
231
+ function sourcesFor(detect) {
232
+ if (!detect) return ['saved'];
233
+ if (detect === true) return ALL_SOURCES;
234
+ // The config comes from the page unvalidated, so a single name or an unknown one can arrive here.
235
+ return /** @type {unknown[]} */ ([detect].flat()).filter(isMarketSource);
236
+ }
237
+
238
+ /**
239
+ * The market the page's sources point to. Reads the DOM and storage only, so it can run on any `env`.
240
+ *
241
+ * @param {MarketsConfig | null | undefined} [markets]
242
+ * @param {MarketEnv} [env]
243
+ * @returns {DetectedMarket} Only the fields found; `{}` with `detect: false`.
244
+ */
245
+ export function detectMarket(markets = cfg.markets, env = pageEnv()) {
246
+ const config = markets ?? {};
247
+ if (config.detect === false) return {};
248
+ /** @type {MarketSources} */
249
+ const sources = {};
250
+ /** @type {DetectedMarket} */
251
+ const found = { sources };
252
+ for (const source of sourcesFor(config.detect)) {
253
+ const hint = readSource(source, config, env);
254
+ if (!hint) continue;
255
+ const country = toCountry(hint.country);
256
+ const language = toLanguage(hint.language);
257
+ const locale = typeof hint.locale === 'string' && hint.locale ? hint.locale : null;
258
+ if (country && !found.country) {
259
+ found.country = country;
260
+ sources.country = source;
261
+ }
262
+ if (language && !found.language) {
263
+ found.language = language;
264
+ sources.language = source;
265
+ }
266
+ if (locale && !found.locale) {
267
+ found.locale = locale;
268
+ sources.locale = source;
269
+ }
270
+ }
271
+ return found;
272
+ }
273
+
274
+ /**
275
+ * `setItem` throws when storage is full or blocked (Safari's private mode); the choice then lasts for this page only.
276
+ *
277
+ * @param {string} key
278
+ * @param {string} value
279
+ */
280
+ function storeIfAllowed(key, value) {
281
+ try {
282
+ localStorage.setItem(key, value);
283
+ } catch {
284
+ return;
285
+ }
111
286
  }
112
287
 
288
+ /**
289
+ * Saves the visitor's choice for the next pages, unless `markets.remember` is false.
290
+ *
291
+ * @param {{ country: CountryCode, language?: string | null | undefined, locale?: string | null | undefined }} market
292
+ */
113
293
  export function rememberMarket(market) {
114
294
  if (cfg.markets?.remember === false) return;
115
- try { localStorage.setItem(storageKey(), JSON.stringify({ country: market.country, language: market.language || undefined, locale: market.locale || undefined })); } catch { /* private mode */ }
295
+ const saved = { country: market.country, language: market.language || undefined, locale: market.locale || undefined };
296
+ storeIfAllowed(storageKey(), JSON.stringify(saved));
116
297
  }
117
298
 
299
+ /** @type {Promise<Localization> | null} */
118
300
  let localizationPromise = null;
119
- export function clearLocalization() { localizationPromise = null; }
120
301
 
121
- /** Countries the store sells to (Shopify Markets), with currency. Cached per page. */
122
- export function getLocalization() {
123
- if (!localizationPromise) {
124
- localizationPromise = gql(`query TetherLocalization {
302
+ /** Forgets the cached localization, for when country names change language. */
303
+ export function clearLocalization() {
304
+ localizationPromise = null;
305
+ }
306
+
307
+ const LOCALIZATION_QUERY = `query TetherLocalization {
125
308
  localization {
126
309
  country { isoCode name currency { isoCode symbol } }
127
310
  language { isoCode name }
128
311
  availableCountries { isoCode name currency { isoCode symbol } availableLanguages { isoCode name } }
129
312
  }
130
- }`).then((d) => d.localization).catch((err) => { localizationPromise = null; throw err; });
313
+ }`;
314
+
315
+ /**
316
+ * The countries the store sells to (Shopify Markets), with their currency. Cached per page; a failed request is not
317
+ * cached, so the next call tries again.
318
+ *
319
+ * @returns {Promise<Localization>}
320
+ */
321
+ export function getLocalization() {
322
+ if (!localizationPromise) {
323
+ /** @type {Promise<{ localization: Localization }>} */
324
+ const loading = gql(LOCALIZATION_QUERY);
325
+ localizationPromise = loading.then(
326
+ (data) => data.localization,
327
+ (/** @type {unknown} */ error) => {
328
+ localizationPromise = null;
329
+ throw error;
330
+ },
331
+ );
131
332
  }
132
333
  return localizationPromise;
133
334
  }
package/src/markup.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * How Tether finds its markup: attribute names carry the configured prefix (`tether-product`, or `wfs-product` with
3
+ * `prefix: 'wfs'`), and so do the selectors and the DOM events built from them.
4
+ */
5
+ import { cfg } from './config.js';
6
+
7
+ /**
8
+ * @param {string} name
9
+ * @returns {string} The prefixed attribute name: `product` → `tether-product`.
10
+ */
11
+ export const attr = (name) => `${cfg.prefix}-${name}`;
12
+
13
+ /**
14
+ * @param {string} name
15
+ * @param {string} [value]
16
+ * @returns {string} A selector for the attribute, with that exact value when given.
17
+ */
18
+ export const sel = (name, value) => (value === undefined ? `[${attr(name)}]` : `[${attr(name)}="${value}"]`);
19
+
20
+ /**
21
+ * @param {Element} el
22
+ * @param {string} name
23
+ */
24
+ export const get = (el, name) => el.getAttribute(attr(name));
25
+
26
+ /**
27
+ * @param {Element} el
28
+ * @param {string} name
29
+ */
30
+ export const has = (el, name) => el.hasAttribute(attr(name));
31
+
32
+ /**
33
+ * Tether's attributes go on HTML elements, so matches are typed as such. An SVG element that carries one still has
34
+ * the `style`, `dataset` and attribute methods Tether uses.
35
+ *
36
+ * @param {ParentNode | null | undefined} root
37
+ * @param {string} selector
38
+ * @returns {HTMLElement[]} The matches in `root`, none without a root.
39
+ */
40
+ export function qsa(root, selector) {
41
+ if (!root) return [];
42
+ return [.../** @type {NodeListOf<HTMLElement>} */ (root.querySelectorAll(selector))];
43
+ }
44
+
45
+ /**
46
+ * @param {Element | null | undefined} root
47
+ * @param {string} selector
48
+ * @param {string} scopeSelector
49
+ * @returns {HTMLElement[]} Matches in `root` whose nearest `scopeSelector` ancestor is `root` itself, not a nested
50
+ * scope.
51
+ */
52
+ export const scoped = (root, selector, scopeSelector) =>
53
+ qsa(root, selector).filter((el) => el.closest(scopeSelector) === root);
54
+
55
+ /**
56
+ * Dispatches the bubbling event `<prefix>:<name>`.
57
+ *
58
+ * @param {string} name
59
+ * @param {unknown} [detail]
60
+ * @param {EventTarget} [target]
61
+ */
62
+ export function emit(name, detail, target = document) {
63
+ target.dispatchEvent(new CustomEvent(`${cfg.prefix}:${name}`, { detail, bubbles: true }));
64
+ }