@occluze/tether 0.6.6 → 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 +776 -432
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +655 -321
  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 +334 -243
  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 +3171 -1998
  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 +68 -284
  58. package/src/account.js +281 -317
  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 +322 -481
  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 -285
  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-QUBHQG3C.js +0 -1
  120. package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
  121. package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
  122. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  123. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  124. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  125. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  126. package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
  127. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  128. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  129. package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
  130. package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
  131. package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
  132. package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
  133. package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
  134. package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/astro.js CHANGED
@@ -10,91 +10,256 @@
10
10
  * PUBLIC_SHOPIFY_DOMAIN=your-store.myshopify.com
11
11
  * PUBLIC_SHOPIFY_STOREFRONT_TOKEN=your-public-storefront-token
12
12
  *
13
- * Options are Tether's config (domain and token override .env). Functions (onError, formatMoney,
14
- * hooks) can't be passed here: set them in window.TETHER_CONFIG in your layout, it is merged in.
15
- * View Transitions (<ClientRouter />) work without extra code. In dev, changes to
16
- * src/shop.config.js restart the dev server (other files: tether({ ..., watch: ['my/config.js'] })).
13
+ * Options are Tether's config (domain and token override .env). Functions (onError, formatMoney, hooks) can't be
14
+ * passed here: set them in window.TETHER_CONFIG in your layout, it is merged in. View Transitions (<ClientRouter />)
15
+ * work without extra code. In dev, changes to src/shop.config.js restart the dev server (other files:
16
+ * tether({ ..., watch: ['my/config.js'] })). Runs in Node, inside Astro's config step.
17
17
  */
18
+ import { isSecretShopifyToken, STORE_ENV_NAMES } from './shopify-values.js';
18
19
 
19
- const ENV_KEYS = {
20
- domain: ['PUBLIC_SHOPIFY_DOMAIN', 'PUBLIC_SHOPIFY_STORE_DOMAIN', 'SHOPIFY_DOMAIN'],
21
- token: ['PUBLIC_SHOPIFY_STOREFRONT_TOKEN', 'PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN', 'SHOPIFY_STOREFRONT_TOKEN'],
22
- };
20
+ /**
21
+ * Astro isn't a dependency, so the parts of its integration API this file uses are described here.
22
+ *
23
+ * @typedef {{ warn: (message: string) => void }} AstroLogger
24
+ * @typedef {(options: { context: 'client', access: 'public', optional: boolean }) => unknown} EnvStringField
25
+ * @typedef {object} AstroConfigSetup The argument of Astro's `astro:config:setup` hook.
26
+ * @property {(stage: 'head-inline' | 'page', content: string) => void} injectScript
27
+ * @property {string} command `dev`, `build`, `preview` or `sync`.
28
+ * @property {{ root?: URL }} config
29
+ * @property {AstroLogger} logger
30
+ * @property {(path: string) => void} [addWatchFile]
31
+ * @property {(config: { env: { schema: Record<string, unknown> } }) => void} [updateConfig]
32
+ */
33
+
34
+ /**
35
+ * @typedef {object} TetherAstroOptions Tether's serializable config, plus the integration's own options.
36
+ * @property {boolean} [inject] Add the Tether script to every page (default true).
37
+ * @property {string[] | null} [watch] Files that restart the dev server when they change.
38
+ */
39
+ /** @typedef {{ domain?: unknown, token?: unknown, [option: string]: unknown }} PageConfig */
40
+ /** @typedef {Record<string, string | undefined>} Env */
23
41
 
24
- /** The mode Astro runs in: --mode, else development for dev and production for build. */
42
+ const DEFAULT_WATCH = ['src/shop.config.js', 'src/shop.config.mjs', 'src/shop.config.ts'];
43
+ /**
44
+ * Astro is an optional peer dependency. A variable specifier keeps both tsc and the bundler from resolving it.
45
+ */
46
+ const ASTRO_CONFIG_MODULE = 'astro/config';
47
+ const ENV_LINE = /^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*?)\s*$/;
48
+ /** A quoted value; a `#` comment may follow the closing quote. */
49
+ const QUOTED_VALUE = /^(["'])(.*?)\1/;
50
+
51
+ /**
52
+ * @param {string} command
53
+ * @param {readonly string[]} [argv]
54
+ * @returns {string} The mode Astro runs in: `--mode`, else development for dev and production for everything else.
55
+ */
25
56
  function modeOf(command, argv = process.argv) {
26
- const i = argv.findIndex((a) => a === '--mode' || a.startsWith('--mode='));
27
- if (i >= 0) return argv[i].includes('=') ? argv[i].split('=')[1] : argv[i + 1];
28
- return command === 'dev' ? 'development' : 'production';
57
+ const defaultMode = command === 'dev' ? 'development' : 'production';
58
+ const index = argv.findIndex((arg) => arg === '--mode' || arg.startsWith('--mode='));
59
+ if (index < 0) return defaultMode;
60
+ const flag = argv[index] ?? '';
61
+ const mode = flag.includes('=') ? flag.split('=')[1] : argv[index + 1];
62
+ return mode ?? defaultMode;
63
+ }
64
+
65
+ /**
66
+ * @param {unknown} error
67
+ * @returns {boolean} True for Node's "no such file" error.
68
+ */
69
+ const isMissingFile = (error) => error instanceof Error && Reflect.get(error, 'code') === 'ENOENT';
70
+
71
+ /**
72
+ * @param {string} path
73
+ * @returns {Promise<string | null>} The file's text, `null` when it doesn't exist: every .env file is optional.
74
+ */
75
+ async function readOptionalFile(path) {
76
+ const { readFile } = await import('node:fs/promises');
77
+ try {
78
+ return await readFile(path, 'utf8');
79
+ } catch (error) {
80
+ if (isMissingFile(error)) return null;
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * @param {string} text A .env file.
87
+ * @param {Env} into Receives each variable; a later line wins.
88
+ */
89
+ function parseEnvFile(text, into) {
90
+ for (const line of text.split(/\r?\n/)) {
91
+ const match = ENV_LINE.exec(line);
92
+ if (!match || line.trim().startsWith('#')) continue;
93
+ const [, name = '', raw = ''] = match;
94
+ const quoted = QUOTED_VALUE.exec(raw);
95
+ into[name] = quoted ? (quoted[2] ?? '') : raw.replace(/\s+#.*$/, '');
96
+ }
29
97
  }
30
98
 
31
- /** .env files the way Vite reads them (later files win), plus process.env on top. */
99
+ /**
100
+ * @param {string} command
101
+ * @param {string} root The project folder.
102
+ * @returns {Promise<Env>} The .env files the way Vite reads them (later files win), with `process.env` on top.
103
+ */
32
104
  async function readEnv(command, root) {
105
+ const path = await import('node:path');
33
106
  const mode = modeOf(command);
34
- const out = {};
35
- try {
36
- const { readFile } = await import('node:fs/promises');
37
- const { join } = await import('node:path');
38
- for (const name of ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]) {
39
- let text;
40
- try { text = await readFile(join(root, name), 'utf8'); } catch { continue; }
41
- for (const line of text.split(/\r?\n/)) {
42
- const m = /^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*?)\s*$/.exec(line);
43
- if (!m || line.trim().startsWith('#')) continue;
44
- let v = m[2];
45
- const q = /^(["'])(.*?)\1/.exec(v); // "quoted" (a # comment may follow)
46
- v = q ? q[2] : v.replace(/\s+#.*$/, '');
47
- out[m[1]] = v;
48
- }
107
+ /** @type {Env} */
108
+ const env = {};
109
+ for (const name of ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]) {
110
+ const text = await readOptionalFile(path.join(root, name));
111
+ if (text !== null) parseEnvFile(text, env);
112
+ }
113
+ return { ...env, ...process.env };
114
+ }
115
+
116
+ /**
117
+ * Fills the store's domain and token from .env when the options don't set them, and refuses a secret token: whatever
118
+ * is read here is written into every page.
119
+ *
120
+ * @param {PageConfig} config Changed in place.
121
+ * @param {Env} env
122
+ * @param {AstroLogger} logger
123
+ * @throws {Error} When the token is a secret Shopify token.
124
+ */
125
+ function applyStoreEnv(config, env, logger) {
126
+ for (const key of /** @type {const} */ (['domain', 'token'])) {
127
+ if (config[key]) continue;
128
+ const name = STORE_ENV_NAMES[key].find((candidate) => env[candidate]);
129
+ if (!name) continue;
130
+ config[key] = env[name];
131
+ // Whatever is read here is written into every page, so a name without PUBLIC_ is a mistake waiting to happen.
132
+ if (!name.startsWith('PUBLIC_')) {
133
+ logger.warn(`${name} is written into every page as Tether's ${key}. Name it PUBLIC_${name} to say so.`);
49
134
  }
50
- } catch { /* not in Node: nothing to read */ }
51
- return { ...out, ...process.env };
135
+ }
136
+ if (typeof config.token === 'string' && isSecretShopifyToken(config.token)) {
137
+ throw new Error(
138
+ 'The Shopify token for Tether is a secret token (shpat_/shpss_/…), which would be published in every page. Use the public Storefront API token from the Headless channel.',
139
+ );
140
+ }
141
+ }
142
+
143
+ /**
144
+ * The options usually come from a file the Astro config imports (src/shop.config.js): the dev server restarts when it
145
+ * changes, so the browser gets the new config too.
146
+ *
147
+ * @param {string} root
148
+ * @param {readonly string[]} files
149
+ * @param {(path: string) => void} addWatchFile
150
+ */
151
+ async function watchConfigFiles(root, files, addWatchFile) {
152
+ const { existsSync } = await import('node:fs');
153
+ const path = await import('node:path');
154
+ for (const file of files) {
155
+ const absolute = path.resolve(root, file);
156
+ if (existsSync(absolute)) addWatchFile(absolute);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * @param {string} specifier
162
+ * @returns {Promise<unknown>} The module, `null` when the package isn't installed.
163
+ */
164
+ async function importIfInstalled(specifier) {
165
+ try {
166
+ /** @type {unknown} */
167
+ const module = await import(specifier);
168
+ return module;
169
+ } catch (error) {
170
+ if (error instanceof Error && Reflect.get(error, 'code') === 'ERR_MODULE_NOT_FOUND') return null;
171
+ throw error;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Declares the store variables in `astro:env` (Astro 5+), so Astro checks them. Astro 4 has no `envField`.
177
+ *
178
+ * @param {NonNullable<AstroConfigSetup['updateConfig']>} updateConfig
179
+ * @param {AstroLogger} logger
180
+ */
181
+ async function declareEnvSchema(updateConfig, logger) {
182
+ const astroConfig = await importIfInstalled(ASTRO_CONFIG_MODULE);
183
+ /** @type {unknown} */
184
+ const envField =
185
+ typeof astroConfig === 'object' && astroConfig !== null ? Reflect.get(astroConfig, 'envField') : null;
186
+ if (typeof envField !== 'object' || envField === null || typeof Reflect.get(envField, 'string') !== 'function')
187
+ return;
188
+ // Checked above: Astro's `envField.string`.
189
+ const fields = /** @type {{ string: EnvStringField }} */ (envField);
190
+ const field = () => fields.string({ context: 'client', access: 'public', optional: true });
191
+ try {
192
+ updateConfig({ env: { schema: { PUBLIC_SHOPIFY_DOMAIN: field(), PUBLIC_SHOPIFY_STOREFRONT_TOKEN: field() } } });
193
+ } catch (error) {
194
+ // Astro versions with `envField` but without a stable `env` option refuse it; the variables still work unchecked.
195
+ logger.warn(`.env variables not declared in astro:env: ${error instanceof Error ? error.message : String(error)}`);
196
+ }
197
+ }
198
+
199
+ /**
200
+ * @param {PageConfig} config Changed in place: functions can't be written into a page.
201
+ * @param {AstroLogger} logger
202
+ */
203
+ function dropFunctions(config, logger) {
204
+ const functions = Object.keys(config).filter((key) => typeof config[key] === 'function');
205
+ for (const key of functions) Reflect.deleteProperty(config, key);
206
+ if (functions.length) {
207
+ logger.warn(
208
+ `${functions.join(', ')}: functions can't be passed here. Set them in window.TETHER_CONFIG in your layout.`,
209
+ );
210
+ }
52
211
  }
53
212
 
213
+ /**
214
+ * @param {AstroConfigSetup['injectScript']} injectScript
215
+ * @param {PageConfig} config
216
+ */
217
+ function injectTether(injectScript, config) {
218
+ // Before any module runs: window.TETHER_CONFIG in the layout doesn't auto-start a second init.
219
+ injectScript('head-inline', 'window.TETHER_INTEGRATION="astro";');
220
+ injectScript(
221
+ 'page',
222
+ [
223
+ "import Tether from '@occluze/tether';",
224
+ // As the base config (like window.TETHER_CONFIG), not as overrides: a visitor's saved market (country, money
225
+ // format) then wins over the defaults, as on a plain script-tag site.
226
+ `window.TETHER_CONFIG = { ...${JSON.stringify({ autoMount: false, ...config })}, ...(window.TETHER_CONFIG || {}) };`,
227
+ 'Tether.init().then(() => Tether.mount(document));',
228
+ ].join('\n'),
229
+ );
230
+ }
231
+
232
+ /**
233
+ * @param {TetherAstroOptions & PageConfig} [options]
234
+ * @returns {{ name: string, hooks: { 'astro:config:setup': (setup: AstroConfigSetup) => Promise<void> } }}
235
+ */
54
236
  export default function tether(options = {}) {
55
- const { inject = true, watch = ['src/shop.config.js', 'src/shop.config.mjs', 'src/shop.config.ts'], ...config } = options;
237
+ const { inject = true, watch = DEFAULT_WATCH, ...pageOptions } = options;
56
238
  return {
57
239
  name: '@occluze/tether',
58
240
  hooks: {
59
- 'astro:config:setup': async ({ injectScript, command, config: astroConfig, logger, addWatchFile, updateConfig }) => {
60
- const root = astroConfig?.root ? decodeURIComponent(new URL(astroConfig.root).pathname) : process.cwd();
61
- // The options usually come from a file the Astro config imports (src/shop.config.js):
62
- // the dev server restarts when it changes, so the browser gets the new config too.
63
- if (command === 'dev' && addWatchFile) {
64
- const { existsSync } = await import('node:fs');
65
- const { resolve } = await import('node:path');
66
- [].concat(watch || []).map((f) => resolve(root, f)).filter((f) => existsSync(f)).forEach((f) => addWatchFile(f));
67
- }
68
- // Astro 5+: declare the store variables, so Astro checks them (astro:env).
69
- if (updateConfig) {
70
- try {
71
- const { envField } = await import('astro/config');
72
- if (envField) {
73
- const field = () => envField.string({ context: 'client', access: 'public', optional: true });
74
- updateConfig({ env: { schema: { PUBLIC_SHOPIFY_DOMAIN: field(), PUBLIC_SHOPIFY_STOREFRONT_TOKEN: field() } } });
75
- }
76
- } catch { /* Astro 4: no astro:env */ }
77
- }
78
- const env = await readEnv(command, root);
79
- for (const [key, names] of Object.entries(ENV_KEYS)) {
80
- if (!config[key]) config[key] = names.map((n) => env[n]).find(Boolean);
81
- }
82
- const bad = Object.entries(config).filter(([, v]) => typeof v === 'function').map(([k]) => k);
83
- bad.forEach((k) => delete config[k]);
84
- if (bad.length) logger.warn(`${bad.join(', ')}: functions can't be passed here. Set them in window.TETHER_CONFIG in your layout.`);
241
+ 'astro:config:setup': async ({
242
+ injectScript,
243
+ command,
244
+ config: astroConfig,
245
+ logger,
246
+ addWatchFile,
247
+ updateConfig,
248
+ }) => {
249
+ const { fileURLToPath } = await import('node:url');
250
+ const root = astroConfig.root ? fileURLToPath(astroConfig.root) : process.cwd();
251
+ if (command === 'dev' && addWatchFile) await watchConfigFiles(root, watch ?? [], addWatchFile);
252
+ if (updateConfig) await declareEnvSchema(updateConfig, logger);
253
+ /** @type {PageConfig} */
254
+ const config = { ...pageOptions };
255
+ applyStoreEnv(config, await readEnv(command, root), logger);
256
+ dropFunctions(config, logger);
85
257
  if (!config.domain || !config.token) {
86
- logger.warn('No Shopify store configured: set PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN in .env (the public Storefront API token).');
258
+ logger.warn(
259
+ 'No Shopify store configured: set PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN in .env (the public Storefront API token).',
260
+ );
87
261
  }
88
- if (!inject) return;
89
- // Before any module runs: window.TETHER_CONFIG in the layout doesn't auto-start a second init.
90
- injectScript('head-inline', 'window.TETHER_INTEGRATION="astro";');
91
- injectScript('page', [
92
- "import Tether from '@occluze/tether';",
93
- // As the base config (like window.TETHER_CONFIG), not as overrides: a visitor's saved market
94
- // (country, money format) then wins over the defaults, as on a plain script-tag site.
95
- `window.TETHER_CONFIG = { ...${JSON.stringify({ autoMount: false, ...config })}, ...(window.TETHER_CONFIG || {}) };`,
96
- 'Tether.init().then(() => Tether.mount(document));',
97
- ].join('\n'));
262
+ if (inject) injectTether(injectScript, config);
98
263
  },
99
264
  },
100
265
  };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Sending the visitor to Shopify's checkout. The URL comes from Shopify at click time, because the cart's checkout URL
3
+ * changes with its market and customer; a logged-in customer's account part may supply its own (`beforeCheckout`).
4
+ */
5
+ import { cfg, reportError } from '../config.js';
6
+ import { TetherError } from '../client.js';
7
+ import { isHttpsUrl } from '../shopify-values.js';
8
+ import { emit } from '../dom.js';
9
+ import { fetchCheckoutUrl } from './mutations.js';
10
+ import { customer, currentCart, savedCartId } from './store.js';
11
+ /** @import { CheckoutTarget } from '../config.js' */
12
+
13
+ /** @typedef {{ kind: 'url', url: string | null } | { kind: 'failed', error: unknown }} CheckoutUrlAnswer */
14
+
15
+ /**
16
+ * @param {string} cartId
17
+ * @returns {Promise<CheckoutUrlAnswer>} Shopify's answer as a value, so the request can run alongside the account's
18
+ * and its failure is read only when its URL is the one used.
19
+ */
20
+ const askCheckoutUrl = (cartId) =>
21
+ fetchCheckoutUrl(cartId).then(
22
+ (url) => ({ kind: /** @type {const} */ ('url'), url }),
23
+ (/** @type {unknown} */ error) => ({ kind: /** @type {const} */ ('failed'), error }),
24
+ );
25
+
26
+ /**
27
+ * @returns {Promise<string | null>} The logged-in customer's checkout URL. When the account can't give one, the
28
+ * failure is reported and the cart's own checkout URL is used, which still checks out (without the login).
29
+ */
30
+ async function accountCheckoutUrl() {
31
+ if (!customer.beforeCheckout) return null;
32
+ try {
33
+ return await customer.beforeCheckout();
34
+ } catch (error) {
35
+ reportError(error, 'account checkout');
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * @param {string} cartId
42
+ * @returns {Promise<string>}
43
+ * @throws {TetherError} `ERROR` without a checkout URL (the cart is empty or Shopify no longer has it).
44
+ */
45
+ async function checkoutUrl(cartId) {
46
+ const cartAnswer = askCheckoutUrl(cartId);
47
+ let url = await accountCheckoutUrl();
48
+ if (!url) {
49
+ const answer = await cartAnswer;
50
+ if (answer.kind === 'failed') throw answer.error;
51
+ url = answer.url;
52
+ }
53
+ if (!url) throw new TetherError('No checkoutUrl (cart empty or expired)');
54
+ if (!customer.accessToken) return url;
55
+ return `${url}${url.includes('?') ? '&' : '?'}logged_in=true`;
56
+ }
57
+
58
+ /**
59
+ * A new tab's `opener` gives the checkout page a handle on this one; a browser that doesn't let a script clear it
60
+ * leaves the tab as it opened.
61
+ *
62
+ * @param {Window} tab
63
+ */
64
+ function detachOpenerIfAllowed(tab) {
65
+ try {
66
+ tab.opener = null;
67
+ } catch {
68
+ // The browser keeps `opener` read-only: see the function's comment.
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Goes to Shopify's checkout. With `_blank` the tab opens inside the click (Safari blocks `window.open` after an
74
+ * await) and gets its URL once there is one.
75
+ *
76
+ * @param {CheckoutTarget} target
77
+ * @returns {Promise<string | null>} The checkout URL, `null` with nothing to check out or after a reported failure.
78
+ */
79
+ export async function checkout(target = cfg.checkoutTarget) {
80
+ const cartId = savedCartId();
81
+ const cart = currentCart();
82
+ if (!cartId || (cart && !cart.totalQuantity)) return null;
83
+ const tab = target === '_blank' ? window.open('', '_blank') : null;
84
+ if (tab) detachOpenerIfAllowed(tab);
85
+ try {
86
+ const url = await checkoutUrl(cartId);
87
+ if (!isHttpsUrl(url)) throw new TetherError('The checkout URL is not https: not opened');
88
+ emit('checkout', { url, cart: currentCart() });
89
+ if (tab && !tab.closed) tab.location.href = url;
90
+ else window.location.href = url;
91
+ return url;
92
+ } catch (error) {
93
+ tab?.close();
94
+ reportError(error, 'checkout failed');
95
+ return null;
96
+ }
97
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The cart drawer: every `[tether-cart-drawer]` on the page opens and closes together, as a modal dialog. Focus moves
3
+ * into the open cart, Tab stays inside it and focus returns on close (`cartFocus`). A page without a drawer (a cart
4
+ * built in React, Svelte or Vue) still has an open state, for `Tether.cart.subscribe`.
5
+ */
6
+ import { cfg } from '../config.js';
7
+ import { sel, get, qsa, emit } from '../dom.js';
8
+
9
+ const FOCUSABLE =
10
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
11
+
12
+ /** Long enough for the drawer's open transition to make its content visible: hidden elements can't take focus. */
13
+ const FOCUS_AFTER_OPEN_MS = 60;
14
+
15
+ /** The open state of a page without a drawer element. */
16
+ let openWithoutDrawer = false;
17
+
18
+ /** @type {HTMLElement | null} Where focus was before the cart opened. */
19
+ let returnFocus = null;
20
+
21
+ /**
22
+ * @param {Element | null} el
23
+ * @returns {el is HTMLElement}
24
+ */
25
+ const isFocusable = (el) => el !== null && 'focus' in el;
26
+
27
+ /**
28
+ * @param {ParentNode} drawer
29
+ * @returns {HTMLElement[]} The visible focusable elements inside `drawer`, in document order.
30
+ */
31
+ const focusables = (drawer) =>
32
+ qsa(drawer, FOCUSABLE).filter((el) => el.offsetParent !== null || el === document.activeElement);
33
+
34
+ /** @returns {HTMLElement[]} Every cart drawer on the page. */
35
+ export const drawers = () => qsa(document, sel('cart-drawer'));
36
+
37
+ /** @param {HTMLElement} drawer */
38
+ const isOpenDrawer = (drawer) => drawer.classList.contains(cfg.cartDrawerOpenClass);
39
+
40
+ /** @returns {boolean} True while a drawer, or the drawer-less cart, is open. */
41
+ export function isOpen() {
42
+ const all = drawers();
43
+ return all.length ? all.some(isOpenDrawer) : openWithoutDrawer;
44
+ }
45
+
46
+ /**
47
+ * `aria-expanded` on the open buttons, and dialog semantics on the drawers unless their markup has its own. A closed
48
+ * drawer is hidden from screen readers and, with `cartFocus`, out of the Tab order (inert), like a closed dialog.
49
+ *
50
+ * @param {boolean} open
51
+ */
52
+ export function syncDrawerAria(open) {
53
+ qsa(document, sel('cart-open')).forEach((button) => {
54
+ button.setAttribute('aria-expanded', String(open));
55
+ });
56
+ drawers().forEach((drawer) => {
57
+ if (!drawer.hasAttribute('role')) drawer.setAttribute('role', 'dialog');
58
+ if (!drawer.hasAttribute('aria-label') && !drawer.hasAttribute('aria-labelledby')) {
59
+ drawer.setAttribute('aria-label', get(drawer, 'cart-label') || 'Cart');
60
+ }
61
+ drawer.setAttribute('aria-modal', String(open));
62
+ if (isOpenDrawer(drawer)) return;
63
+ drawer.setAttribute('aria-hidden', 'true');
64
+ if (cfg.cartFocus) drawer.inert = true;
65
+ });
66
+ }
67
+
68
+ /** @param {HTMLElement} drawer */
69
+ function focusInto(drawer) {
70
+ const target = focusables(drawer)[0] ?? drawer;
71
+ if (target === drawer && !drawer.hasAttribute('tabindex')) drawer.tabIndex = -1;
72
+ target.focus({ preventScroll: true });
73
+ }
74
+
75
+ /** Opens every drawer except `tether-cart-drawer="manual"` ones, which the site opens itself. */
76
+ export function openCart() {
77
+ if (isOpen()) return;
78
+ openWithoutDrawer = true;
79
+ const opened = drawers().filter((drawer) => get(drawer, 'cart-drawer') !== 'manual');
80
+ opened.forEach((drawer) => {
81
+ drawer.inert = false;
82
+ drawer.classList.add(cfg.cartDrawerOpenClass);
83
+ drawer.setAttribute('aria-hidden', 'false');
84
+ });
85
+ syncDrawerAria(true);
86
+ const [first] = opened;
87
+ if (cfg.cartFocus && first) {
88
+ returnFocus = isFocusable(document.activeElement) ? document.activeElement : null;
89
+ setTimeout(() => {
90
+ focusInto(first);
91
+ }, FOCUS_AFTER_OPEN_MS);
92
+ }
93
+ cfg.onCartOpen?.(opened);
94
+ emit('cart-toggled', { open: true });
95
+ }
96
+
97
+ export function closeCart() {
98
+ if (!isOpen()) return;
99
+ openWithoutDrawer = false;
100
+ const all = drawers();
101
+ const hadFocus = all.some((drawer) => drawer.contains(document.activeElement));
102
+ all.forEach((drawer) => {
103
+ drawer.classList.remove(cfg.cartDrawerOpenClass);
104
+ drawer.setAttribute('aria-hidden', 'true');
105
+ if (cfg.cartFocus) drawer.inert = true;
106
+ });
107
+ syncDrawerAria(false);
108
+ if (hadFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
109
+ returnFocus = null;
110
+ cfg.onCartClose?.(all);
111
+ emit('cart-toggled', { open: false });
112
+ }
113
+
114
+ export function toggleCart() {
115
+ if (isOpen()) closeCart();
116
+ else openCart();
117
+ }
118
+
119
+ /**
120
+ * Keeps Tab inside the open drawer (`cartFocus`): from the last focusable element it wraps to the first, with Shift
121
+ * from the first to the last, and from outside the drawer it goes in. For a document `keydown` listener.
122
+ *
123
+ * @param {KeyboardEvent} event
124
+ */
125
+ export function trapFocus(event) {
126
+ if (event.key !== 'Tab' || !cfg.cartFocus || !isOpen()) return;
127
+ const drawer = drawers().find(isOpenDrawer);
128
+ if (!drawer) return;
129
+ const inside = focusables(drawer);
130
+ const first = inside[0];
131
+ const last = inside.at(-1);
132
+ if (!first || !last) return;
133
+ const active = document.activeElement;
134
+ const leaving = event.shiftKey ? active === first : active === last;
135
+ if (drawer.contains(active) && !leaving) return;
136
+ event.preventDefault();
137
+ (event.shiftKey ? last : first).focus();
138
+ }