@occluze/tether 0.6.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/README.md +11 -11
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
  4. package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
  5. package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
  6. package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
  7. package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
  8. package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
  9. package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
  10. package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
  11. package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
  12. package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
  13. package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
  14. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  15. package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
  16. package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
  17. package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
  18. package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
  19. package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
  20. package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
  21. package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -2
  24. package/dist/presets/barba.esm.js +19 -23
  25. package/dist/tether.account-pages.js +320 -365
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +242 -491
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +66 -88
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +36 -33
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +28 -36
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +1270 -894
  36. package/dist/tether.markets.js +73 -90
  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 +44 -68
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  43. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  44. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  45. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  46. package/dist/types/server/src/shared/json.d.ts +25 -0
  47. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  48. package/dist/types/src/account/addresses-view.d.ts +5 -0
  49. package/dist/types/src/account/answers.d.ts +40 -0
  50. package/dist/types/src/account/markup.d.ts +19 -0
  51. package/dist/types/src/account/operations.d.ts +41 -0
  52. package/dist/types/src/account/order-lines.d.ts +13 -0
  53. package/dist/types/src/account/order-list.d.ts +12 -0
  54. package/dist/types/src/account/order-page.d.ts +6 -0
  55. package/dist/types/src/account/page-answers.d.ts +182 -0
  56. package/dist/types/src/account/page-events.d.ts +2 -0
  57. package/dist/types/src/account/server-client.d.ts +60 -0
  58. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  59. package/dist/types/src/account-api.d.ts +18 -0
  60. package/dist/types/src/account-marks.d.ts +15 -0
  61. package/dist/types/src/account-pages.d.ts +8 -0
  62. package/dist/types/src/account.d.ts +64 -0
  63. package/dist/types/src/amounts.d.ts +12 -0
  64. package/dist/types/src/analytics-shopify.d.ts +41 -0
  65. package/dist/types/src/analytics.d.ts +2 -0
  66. package/dist/types/src/api-version.d.ts +28 -0
  67. package/dist/types/src/astro.d.ts +42 -0
  68. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  69. package/dist/types/src/cart/checkout.d.ts +16 -0
  70. package/dist/types/src/cart/drawer.d.ts +18 -0
  71. package/dist/types/src/cart/mutations.d.ts +79 -0
  72. package/dist/types/src/cart/render.d.ts +17 -0
  73. package/dist/types/src/cart/store.d.ts +182 -0
  74. package/dist/types/src/cart.d.ts +106 -0
  75. package/dist/types/src/client.d.ts +100 -0
  76. package/dist/types/src/collection.d.ts +25 -0
  77. package/dist/types/src/config-sections.d.ts +40 -0
  78. package/dist/types/src/config.d.ts +185 -0
  79. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  80. package/dist/types/src/controls/discount.d.ts +2 -0
  81. package/dist/types/src/controls/extras.d.ts +5 -0
  82. package/dist/types/src/controls/navigation.d.ts +12 -0
  83. package/dist/types/src/diagnose-markup.d.ts +19 -0
  84. package/dist/types/src/diagnose.d.ts +60 -0
  85. package/dist/types/src/dom.d.ts +75 -0
  86. package/dist/types/src/extras.d.ts +21 -0
  87. package/dist/types/src/fragments.d.ts +158 -0
  88. package/dist/types/src/globals.d.ts +15 -0
  89. package/dist/types/src/images.d.ts +20 -0
  90. package/dist/types/src/index.d.ts +167 -0
  91. package/dist/types/src/initial.d.ts +56 -0
  92. package/dist/types/src/lazy.d.ts +28 -0
  93. package/dist/types/src/loader.d.ts +84 -0
  94. package/dist/types/src/market-codes.d.ts +6 -0
  95. package/dist/types/src/markets-ui.d.ts +32 -0
  96. package/dist/types/src/markets.d.ts +72 -0
  97. package/dist/types/src/markup.d.ts +131 -0
  98. package/dist/types/src/mounting.d.ts +46 -0
  99. package/dist/types/src/page-market.d.ts +27 -0
  100. package/dist/types/src/presets/barba.d.ts +56 -0
  101. package/dist/types/src/product-events.d.ts +2 -0
  102. package/dist/types/src/product-render.d.ts +17 -0
  103. package/dist/types/src/product-shape.d.ts +88 -0
  104. package/dist/types/src/product-state.d.ts +110 -0
  105. package/dist/types/src/product.d.ts +66 -0
  106. package/dist/types/src/repeaters.d.ts +16 -0
  107. package/dist/types/src/search.d.ts +33 -0
  108. package/dist/types/src/selling-plans.d.ts +64 -0
  109. package/dist/types/src/server.d.ts +88 -0
  110. package/dist/types/src/shopify-values.d.ts +46 -0
  111. package/dist/types/src/status.d.ts +33 -0
  112. package/dist/types/src/storage.d.ts +12 -0
  113. package/dist/types/src/store-html.d.ts +39 -0
  114. package/dist/types/src/storefront/catalog.d.ts +38 -0
  115. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  116. package/dist/types/src/variant-render.d.ts +14 -0
  117. package/package.json +24 -23
  118. package/src/account/addresses-view.ts +58 -0
  119. package/src/account/answers.ts +79 -0
  120. package/src/account/markup.ts +36 -0
  121. package/src/account/operations.ts +145 -0
  122. package/src/account/{order-lines.js → order-lines.ts} +20 -41
  123. package/src/account/order-list.ts +107 -0
  124. package/src/account/order-page.ts +137 -0
  125. package/src/account/page-answers.ts +317 -0
  126. package/src/account/{page-events.js → page-events.ts} +55 -90
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/{account-pages.js → account-pages.ts} +29 -37
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
  135. package/src/analytics.ts +315 -0
  136. package/src/{api-version.js → api-version.ts} +25 -31
  137. package/src/{astro.js → astro.ts} +110 -104
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/{drawer.js → drawer.ts} +18 -31
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/{render.js → render.ts} +68 -96
  143. package/src/cart/store.ts +279 -0
  144. package/src/{cart.js → cart.ts} +82 -128
  145. package/src/client.ts +391 -0
  146. package/src/{collection.js → collection.ts} +30 -38
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
  150. package/src/controls/{discount.js → discount.ts} +13 -34
  151. package/src/controls/{extras.js → extras.ts} +15 -24
  152. package/src/controls/{navigation.js → navigation.ts} +24 -30
  153. package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
  154. package/src/{diagnose.js → diagnose.ts} +95 -96
  155. package/src/dom.ts +268 -0
  156. package/src/{extras.js → extras.ts} +43 -52
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/{images.js → images.ts} +30 -43
  160. package/src/index.ts +415 -0
  161. package/src/initial.ts +101 -0
  162. package/src/lazy-iife.ts +197 -0
  163. package/src/lazy.ts +42 -0
  164. package/src/{loader.js → loader.ts} +149 -123
  165. package/src/market-codes.ts +6 -0
  166. package/src/{markets-ui.js → markets-ui.ts} +91 -68
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/{mounting.js → mounting.ts} +88 -101
  170. package/src/{page-market.js → page-market.ts} +32 -38
  171. package/src/part-integrity.ts +7 -0
  172. package/src/parts/account-pages.ts +7 -0
  173. package/src/parts/account.ts +7 -0
  174. package/src/parts/analytics.ts +7 -0
  175. package/src/parts/diagnose.ts +7 -0
  176. package/src/parts/extras.ts +7 -0
  177. package/src/parts/markets.ts +7 -0
  178. package/src/parts/search.ts +7 -0
  179. package/src/presets/barba.ts +194 -0
  180. package/src/{product-events.js → product-events.ts} +33 -48
  181. package/src/{product-render.js → product-render.ts} +38 -77
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/{product.js → product.ts} +90 -126
  185. package/src/{repeaters.js → repeaters.ts} +40 -52
  186. package/src/{search.js → search.ts} +85 -103
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/{shopify-values.js → shopify-values.ts} +24 -22
  190. package/src/{status.js → status.ts} +34 -54
  191. package/src/storage.ts +34 -0
  192. package/src/store-html.ts +359 -0
  193. package/src/storefront/catalog.ts +196 -0
  194. package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
  195. package/src/{variant-render.js → variant-render.ts} +24 -50
  196. package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
  197. package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
  198. package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
  199. package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
  200. package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
  201. package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
  202. package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
  203. package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
  204. package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
  205. package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
  206. package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
  207. package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
  208. package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
  209. package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
  210. package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
  211. package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
  212. package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
  213. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
  214. package/dist/esm/chunks/search-OU75RNQL.js +0 -1
  215. package/src/account/addresses-view.js +0 -92
  216. package/src/account/browser-session.js +0 -359
  217. package/src/account/markup.js +0 -48
  218. package/src/account/operations.js +0 -180
  219. package/src/account/order-list.js +0 -91
  220. package/src/account/order-page.js +0 -192
  221. package/src/account/server-client.js +0 -172
  222. package/src/account/shared.js +0 -111
  223. package/src/account/shopify-auth.js +0 -150
  224. package/src/account-api.js +0 -118
  225. package/src/account-links.js +0 -35
  226. package/src/account.js +0 -342
  227. package/src/amounts.js +0 -36
  228. package/src/analytics.js +0 -352
  229. package/src/cart/checkout.js +0 -97
  230. package/src/cart/mutations.js +0 -234
  231. package/src/cart/store.js +0 -326
  232. package/src/client.js +0 -267
  233. package/src/config.js +0 -349
  234. package/src/dom.js +0 -287
  235. package/src/fragments.js +0 -237
  236. package/src/index.js +0 -353
  237. package/src/initial.js +0 -87
  238. package/src/lazy-iife.js +0 -116
  239. package/src/lazy.js +0 -39
  240. package/src/market-codes.js +0 -35
  241. package/src/markets.js +0 -334
  242. package/src/markup.js +0 -64
  243. package/src/parts/account-pages.js +0 -6
  244. package/src/parts/account.js +0 -6
  245. package/src/parts/analytics.js +0 -6
  246. package/src/parts/diagnose.js +0 -6
  247. package/src/parts/extras.js +0 -6
  248. package/src/parts/markets.js +0 -6
  249. package/src/parts/search.js +0 -6
  250. package/src/presets/barba.js +0 -190
  251. package/src/product-state.js +0 -318
  252. package/src/selling-plans.js +0 -154
  253. package/src/server.js +0 -215
  254. package/src/store-html.js +0 -0
  255. package/src/storefront/catalog.js +0 -199
  256. package/types/astro.d.ts +0 -28
  257. package/types/barba.d.ts +0 -16
  258. package/types/index.d.ts +0 -577
  259. package/types/server.d.ts +0 -182
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Shopify API versions are quarterly (YYYY-01, -04, -07, -10) and supported for 12 months. Tether pins a dated version
3
- * and never targets `unstable` (it changes without notice). Used by the diagnose part and the release scripts.
3
+ * and never targets `unstable` (it changes without notice). Used by the config, the diagnose part and the release
4
+ * scripts.
4
5
  */
5
6
 
7
+ import type { ApiVersion } from '../server/src/shared/api-versions.ts';
8
+
6
9
  const QUARTERLY_VERSION = /^(\d{4})-(01|04|07|10)$/;
7
10
  const MONTHS_PER_QUARTER = 3;
8
11
  const QUARTERS_PER_YEAR = 4;
@@ -11,54 +14,45 @@ const MONTHS_SUPPORTED = 12;
11
14
  const UPGRADE_WARNING_MONTHS = 3;
12
15
  const AVERAGE_MONTH_MS = 30.44 * 24 * 3600 * 1000;
13
16
 
14
- /**
15
- * @typedef {object} ApiVersionStatus
16
- * @property {string} version
17
- * @property {boolean} ok Released and still supported.
18
- * @property {string} note What to do about it, for people.
19
- * @property {number} [monthsLeft] Months of support left, rounded; negative once support ended.
20
- * @property {string} [next] The version after this one.
21
- */
17
+ export interface ApiVersionStatus {
18
+ version: string;
19
+ /** Released and still supported. */
20
+ ok: boolean;
21
+ /** What to do about it, for people. */
22
+ note: string;
23
+ /** Months of support left, rounded; negative once support ended. */
24
+ monthsLeft?: number;
25
+ /** The version after this one. */
26
+ next?: string;
27
+ }
22
28
 
23
- /**
24
- * @param {unknown} version
25
- * @returns {{ year: number, month: number } | null}
26
- */
27
- function parseVersion(version) {
29
+ function parseVersion(version: unknown): { year: number; month: number } | null {
28
30
  const match = QUARTERLY_VERSION.exec(typeof version === 'string' ? version : '');
29
31
  if (!match) return null;
30
32
  return { year: Number(match[1]), month: Number(match[2]) };
31
33
  }
32
34
 
33
- /**
34
- * @param {number} year
35
- * @param {number} month 1 to 12.
36
- */
37
- const formatVersion = (year, month) => `${year}-${String(month).padStart(2, '0')}`;
35
+ /** @returns True for a dated version such as `2026-07`. */
36
+ export const isQuarterlyVersion = (version: unknown): version is ApiVersion => parseVersion(version) !== null;
37
+
38
+ /** @param month 1 to 12. */
39
+ const formatVersion = (year: number, month: number) => `${year}-${String(month).padStart(2, '0')}`;
38
40
 
39
41
  /**
40
42
  * `'2026-07'` → `'2026-10'`, `'2026-10'` → `'2027-01'`.
41
43
  *
42
- * @param {string} version
43
- * @param {number} [steps] Quarters ahead.
44
- * @returns {string}
44
+ * @param steps Quarters ahead.
45
45
  * @throws {Error} When `version` isn't a quarterly version.
46
46
  */
47
- export function nextApiVersion(version, steps = 1) {
47
+ export function nextApiVersion(version: string, steps = 1): string {
48
48
  const parsed = parseVersion(version);
49
49
  if (!parsed) throw new Error(`not a quarterly Shopify API version: ${version}`);
50
50
  const quarter = parsed.year * QUARTERS_PER_YEAR + (parsed.month - 1) / MONTHS_PER_QUARTER + steps;
51
51
  return formatVersion(Math.floor(quarter / QUARTERS_PER_YEAR), (quarter % QUARTERS_PER_YEAR) * MONTHS_PER_QUARTER + 1);
52
52
  }
53
53
 
54
- /**
55
- * The support window of `version`, relative to `now`.
56
- *
57
- * @param {string} version
58
- * @param {Date} [now]
59
- * @returns {ApiVersionStatus}
60
- */
61
- export function apiVersionStatus(version, now = new Date()) {
54
+ /** The support window of `version`, relative to `now`. */
55
+ export function apiVersionStatus(version: string, now = new Date()): ApiVersionStatus {
62
56
  if (version === 'unstable') {
63
57
  return { version, ok: false, note: '"unstable" changes without notice: pin a dated version such as 2026-07' };
64
58
  }
@@ -10,36 +10,66 @@
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, hooks) can't be
13
+ * Options are Tether's config (domain and token override .env), and may be the object createStorefront() is given too:
14
+ * its own options (siteUrl, pageSize) are left out of the page. Functions (onError, formatMoney, hooks) can't be
14
15
  * passed here: set them in window.TETHER_CONFIG in your layout, it is merged in. View Transitions (<ClientRouter />)
15
16
  * work without extra code. In dev, changes to src/shop.config.js restart the dev server (other files:
16
17
  * tether({ ..., watch: ['my/config.js'] })). Runs in Node, inside Astro's config step.
17
18
  */
18
- import { isSecretShopifyToken, STORE_ENV_NAMES } from './shopify-values.js';
19
+ import { DEFAULTS } from './config.ts';
20
+ import type { TetherOptions } from './config.ts';
21
+ import type { StorefrontOptions } from './server.ts';
22
+ import { isSecretShopifyToken, STORE_ENV_NAMES } from './shopify-values.ts';
23
+ import { messageOf } from '../server/src/shared/error-message.ts';
19
24
 
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
- */
25
+ // Astro isn't a dependency, so the parts of its integration API this file uses are described here.
26
+ interface AstroLogger {
27
+ warn: (message: string) => void;
28
+ }
29
+ type EnvStringField = (options: { context: 'client'; access: 'public'; optional: boolean }) => unknown;
30
+ /** The argument of Astro's `astro:config:setup` hook. */
31
+ interface AstroConfigSetup {
32
+ injectScript: (stage: 'head-inline' | 'page', content: string) => void;
33
+ /** `dev`, `build`, `preview` or `sync`. */
34
+ command: string;
35
+ config: { root?: URL };
36
+ logger: AstroLogger;
37
+ addWatchFile?: (path: string) => void;
38
+ /** Takes a partial Astro config. A method, so that Astro's own `DeepPartial<AstroConfig>` parameter fits. */
39
+ updateConfig?(config: { env?: { schema?: Record<string, unknown> } }): unknown;
40
+ }
33
41
 
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 */
42
+ /** The config options a page can be handed as JSON: all but the functions. */
43
+ type SerializableOptions = {
44
+ [
45
+ Key in keyof TetherOptions as Exclude<TetherOptions[Key], null | undefined> extends (...args: never[]) => unknown
46
+ ? never
47
+ : Key
48
+ ]?: TetherOptions[Key];
49
+ };
50
+
51
+ /** `createStorefront()`'s own options that a config shared with it may hold, which the page leaves out. */
52
+ type ServerOnlyOptions = Pick<StorefrontOptions, 'siteUrl' | 'pageSize'>;
53
+
54
+ /** Tether's serializable config and the storefront's own options, plus the integration's own options. */
55
+ interface TetherAstroOptions extends SerializableOptions, ServerOnlyOptions {
56
+ /** Add the Tether script to every page (default true). */
57
+ inject?: boolean;
58
+ /** Files that restart the dev server when they change. */
59
+ watch?: string[] | null;
60
+ }
61
+ interface PageConfig {
62
+ domain?: unknown;
63
+ token?: unknown;
64
+ [option: string]: unknown;
65
+ }
66
+ type Env = Record<string, string | undefined>;
67
+
68
+ export type { TetherAstroOptions, AstroConfigSetup };
41
69
 
42
70
  const DEFAULT_WATCH = ['src/shop.config.js', 'src/shop.config.mjs', 'src/shop.config.ts'];
71
+ /** The keys of `ServerOnlyOptions`: the browser has no use for them. */
72
+ const SERVER_OPTIONS = new Set(['siteUrl', 'pageSize']);
43
73
  /**
44
74
  * Astro is an optional peer dependency. A variable specifier keeps both tsc and the bundler from resolving it.
45
75
  */
@@ -48,12 +78,8 @@ const ENV_LINE = /^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*?)\s*$/;
48
78
  /** A quoted value; a `#` comment may follow the closing quote. */
49
79
  const QUOTED_VALUE = /^(["'])(.*?)\1/;
50
80
 
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
- */
56
- function modeOf(command, argv = process.argv) {
81
+ /** @returns The mode Astro runs in: `--mode`, else development for dev and production for everything else. */
82
+ function modeOf(command: string, argv: readonly string[] = process.argv): string {
57
83
  const defaultMode = command === 'dev' ? 'development' : 'production';
58
84
  const index = argv.findIndex((arg) => arg === '--mode' || arg.startsWith('--mode='));
59
85
  if (index < 0) return defaultMode;
@@ -62,17 +88,11 @@ function modeOf(command, argv = process.argv) {
62
88
  return mode ?? defaultMode;
63
89
  }
64
90
 
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';
91
+ /** @returns True for Node's "no such file" error. */
92
+ const isMissingFile = (error: unknown): boolean => error instanceof Error && 'code' in error && error.code === 'ENOENT';
70
93
 
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) {
94
+ /** @returns The file's text, `null` when it doesn't exist: every .env file is optional. */
95
+ async function readOptionalFile(path: string): Promise<string | null> {
76
96
  const { readFile } = await import('node:fs/promises');
77
97
  try {
78
98
  return await readFile(path, 'utf8');
@@ -83,10 +103,10 @@ async function readOptionalFile(path) {
83
103
  }
84
104
 
85
105
  /**
86
- * @param {string} text A .env file.
87
- * @param {Env} into Receives each variable; a later line wins.
106
+ * @param text A .env file.
107
+ * @param into Receives each variable; a later line wins.
88
108
  */
89
- function parseEnvFile(text, into) {
109
+ function parseEnvFile(text: string, into: Env) {
90
110
  for (const line of text.split(/\r?\n/)) {
91
111
  const match = ENV_LINE.exec(line);
92
112
  if (!match || line.trim().startsWith('#')) continue;
@@ -97,15 +117,13 @@ function parseEnvFile(text, into) {
97
117
  }
98
118
 
99
119
  /**
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.
120
+ * @param root The project folder.
121
+ * @returns The .env files the way Vite reads them (later files win), with `process.env` on top.
103
122
  */
104
- async function readEnv(command, root) {
123
+ async function readEnv(command: string, root: string): Promise<Env> {
105
124
  const path = await import('node:path');
106
125
  const mode = modeOf(command);
107
- /** @type {Env} */
108
- const env = {};
126
+ const env: Env = {};
109
127
  for (const name of ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]) {
110
128
  const text = await readOptionalFile(path.join(root, name));
111
129
  if (text !== null) parseEnvFile(text, env);
@@ -117,13 +135,11 @@ async function readEnv(command, root) {
117
135
  * Fills the store's domain and token from .env when the options don't set them, and refuses a secret token: whatever
118
136
  * is read here is written into every page.
119
137
  *
120
- * @param {PageConfig} config Changed in place.
121
- * @param {Env} env
122
- * @param {AstroLogger} logger
138
+ * @param config Changed in place.
123
139
  * @throws {Error} When the token is a secret Shopify token.
124
140
  */
125
- function applyStoreEnv(config, env, logger) {
126
- for (const key of /** @type {const} */ (['domain', 'token'])) {
141
+ function applyStoreEnv(config: PageConfig, env: Env, logger: AstroLogger) {
142
+ for (const key of ['domain', 'token'] as const) {
127
143
  if (config[key]) continue;
128
144
  const name = STORE_ENV_NAMES[key].find((candidate) => env[candidate]);
129
145
  if (!name) continue;
@@ -143,12 +159,8 @@ function applyStoreEnv(config, env, logger) {
143
159
  /**
144
160
  * The options usually come from a file the Astro config imports (src/shop.config.js): the dev server restarts when it
145
161
  * 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
162
  */
151
- async function watchConfigFiles(root, files, addWatchFile) {
163
+ async function watchConfigFiles(root: string, files: readonly string[], addWatchFile: (path: string) => void) {
152
164
  const { existsSync } = await import('node:fs');
153
165
  const path = await import('node:path');
154
166
  for (const file of files) {
@@ -157,50 +169,46 @@ async function watchConfigFiles(root, files, addWatchFile) {
157
169
  }
158
170
  }
159
171
 
160
- /**
161
- * @param {string} specifier
162
- * @returns {Promise<unknown>} The module, `null` when the package isn't installed.
163
- */
164
- async function importIfInstalled(specifier) {
172
+ /** @returns The module, `null` when the package isn't installed. */
173
+ async function importIfInstalled(specifier: string): Promise<unknown> {
165
174
  try {
166
- /** @type {unknown} */
167
- const module = await import(specifier);
175
+ const module: unknown = await import(specifier);
168
176
  return module;
169
177
  } catch (error) {
170
- if (error instanceof Error && Reflect.get(error, 'code') === 'ERR_MODULE_NOT_FOUND') return null;
178
+ if (error instanceof Error && 'code' in error && error.code === 'ERR_MODULE_NOT_FOUND') return null;
171
179
  throw error;
172
180
  }
173
181
  }
174
182
 
183
+ /**
184
+ * @param envField `astro/config`'s `envField`, which Astro 4 doesn't have.
185
+ * @returns True when it has the `string` field Astro 5 documents.
186
+ */
187
+ const hasStringField = (envField: unknown): envField is { string: EnvStringField } =>
188
+ typeof envField === 'object' && envField !== null && 'string' in envField && typeof envField.string === 'function';
189
+
175
190
  /**
176
191
  * 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
192
  */
181
- async function declareEnvSchema(updateConfig, logger) {
193
+ async function declareEnvSchema(setup: AstroConfigSetup) {
194
+ if (!setup.updateConfig) return;
182
195
  const astroConfig = await importIfInstalled(ASTRO_CONFIG_MODULE);
183
- /** @type {unknown} */
184
196
  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 });
197
+ typeof astroConfig === 'object' && astroConfig !== null && 'envField' in astroConfig ? astroConfig.envField : null;
198
+ if (!hasStringField(envField)) return;
199
+ const field = () => envField.string({ context: 'client', access: 'public', optional: true });
191
200
  try {
192
- updateConfig({ env: { schema: { PUBLIC_SHOPIFY_DOMAIN: field(), PUBLIC_SHOPIFY_STOREFRONT_TOKEN: field() } } });
201
+ setup.updateConfig({
202
+ env: { schema: { PUBLIC_SHOPIFY_DOMAIN: field(), PUBLIC_SHOPIFY_STOREFRONT_TOKEN: field() } },
203
+ });
193
204
  } catch (error) {
194
205
  // 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)}`);
206
+ setup.logger.warn(`.env variables not declared in astro:env: ${messageOf(error)}`);
196
207
  }
197
208
  }
198
209
 
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) {
210
+ /** @param config Changed in place: functions can't be written into a page. */
211
+ function dropFunctions(config: PageConfig, logger: AstroLogger) {
204
212
  const functions = Object.keys(config).filter((key) => typeof config[key] === 'function');
205
213
  for (const key of functions) Reflect.deleteProperty(config, key);
206
214
  if (functions.length) {
@@ -210,11 +218,16 @@ function dropFunctions(config, logger) {
210
218
  }
211
219
  }
212
220
 
213
- /**
214
- * @param {AstroConfigSetup['injectScript']} injectScript
215
- * @param {PageConfig} config
216
- */
217
- function injectTether(injectScript, config) {
221
+ /** @param config Changed in place: only browser options are written into the page. */
222
+ function keepBrowserOptions(config: PageConfig, logger: AstroLogger) {
223
+ for (const key of Object.keys(config)) {
224
+ if (key in DEFAULTS) continue;
225
+ Reflect.deleteProperty(config, key);
226
+ if (!SERVER_OPTIONS.has(key)) logger.warn(`${key} is not a Tether option: left out of the page.`);
227
+ }
228
+ }
229
+
230
+ function injectTether(injectScript: AstroConfigSetup['injectScript'], config: PageConfig) {
218
231
  // Before any module runs: window.TETHER_CONFIG in the layout doesn't auto-start a second init.
219
232
  injectScript('head-inline', 'window.TETHER_INTEGRATION="astro";');
220
233
  injectScript(
@@ -229,31 +242,24 @@ function injectTether(injectScript, config) {
229
242
  );
230
243
  }
231
244
 
232
- /**
233
- * @param {TetherAstroOptions & PageConfig} [options]
234
- * @returns {{ name: string, hooks: { 'astro:config:setup': (setup: AstroConfigSetup) => Promise<void> } }}
235
- */
236
- export default function tether(options = {}) {
245
+ export default function tether(options: TetherAstroOptions = {}): {
246
+ name: string;
247
+ hooks: { 'astro:config:setup': (setup: AstroConfigSetup) => Promise<void> };
248
+ } {
237
249
  const { inject = true, watch = DEFAULT_WATCH, ...pageOptions } = options;
238
250
  return {
239
251
  name: '@occluze/tether',
240
252
  hooks: {
241
- 'astro:config:setup': async ({
242
- injectScript,
243
- command,
244
- config: astroConfig,
245
- logger,
246
- addWatchFile,
247
- updateConfig,
248
- }) => {
253
+ 'astro:config:setup': async (setup) => {
254
+ const { injectScript, command, config: astroConfig, logger, addWatchFile } = setup;
249
255
  const { fileURLToPath } = await import('node:url');
250
256
  const root = astroConfig.root ? fileURLToPath(astroConfig.root) : process.cwd();
251
257
  if (command === 'dev' && addWatchFile) await watchConfigFiles(root, watch ?? [], addWatchFile);
252
- if (updateConfig) await declareEnvSchema(updateConfig, logger);
253
- /** @type {PageConfig} */
254
- const config = { ...pageOptions };
258
+ await declareEnvSchema(setup);
259
+ const config: PageConfig = { ...pageOptions };
255
260
  applyStoreEnv(config, await readEnv(command, root), logger);
256
261
  dropFunctions(config, logger);
262
+ keepBrowserOptions(config, logger);
257
263
  if (!config.domain || !config.token) {
258
264
  logger.warn(
259
265
  'No Shopify store configured: set PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN in .env (the public Storefront API token).',
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The structure of Tether's cart fragment, checked on a Storefront answer before it is used as a `ShopifyCart`. The
3
+ * cart's render, its totals and the cart API walk its lines and prices without checking each step. Text fields are
4
+ * written as text, so they need no check.
5
+ */
6
+ import { MONEY } from '../product-shape.ts';
7
+ import type { Shape } from '../client.ts';
8
+
9
+ const LINE = {
10
+ id: 'string',
11
+ quantity: 'number',
12
+ attributes: ['object'],
13
+ cost: { totalAmount: MONEY, subtotalAmount: MONEY, amountPerQuantity: MONEY },
14
+ merchandise: { id: 'string', selectedOptions: ['object'], product: 'object' },
15
+ } as const satisfies Shape;
16
+
17
+ export const CART = {
18
+ id: 'string',
19
+ checkoutUrl: 'string',
20
+ totalQuantity: 'number',
21
+ attributes: ['object'],
22
+ discountCodes: ['object'],
23
+ cost: { totalAmount: MONEY, subtotalAmount: MONEY },
24
+ lines: { nodes: [LINE] },
25
+ } as const satisfies Shape;
@@ -0,0 +1,101 @@
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; the account part resolves a logged-in customer's own (`setCheckoutResolver`).
4
+ */
5
+ import { cfg, reportError } from '../config.ts';
6
+ import { TetherError } from '../client.ts';
7
+ import { isHttpsUrl } from '../shopify-values.ts';
8
+ import { emit } from '../dom.ts';
9
+ import { fetchCheckoutUrl } from './mutations.ts';
10
+ import { currentCart, savedCartId } from './store.ts';
11
+ import type { CheckoutTarget } from '../config.ts';
12
+
13
+ type CheckoutUrlAnswer = { kind: 'url'; url: string | null } | { kind: 'failed'; error: unknown };
14
+
15
+ /**
16
+ * The logged-in customer's checkout URL for the cart; `null` when nobody is logged in, so the cart's own checkout URL
17
+ * is used.
18
+ */
19
+ type CheckoutResolver = () => Promise<string | null>;
20
+
21
+ let resolveAccountCheckout: CheckoutResolver | null = null;
22
+
23
+ /** Sets the account part's checkout URL, or clears it with `null`. A URL it answers opens checkout logged in. */
24
+ export function setCheckoutResolver(resolver: CheckoutResolver | null): void {
25
+ resolveAccountCheckout = resolver;
26
+ }
27
+
28
+ /**
29
+ * @returns Shopify's answer as a value, so the request can run alongside the account's and its failure is read only
30
+ * when its URL is the one used.
31
+ */
32
+ const askCheckoutUrl = (cartId: string): Promise<CheckoutUrlAnswer> =>
33
+ fetchCheckoutUrl(cartId).then(
34
+ (url) => ({ kind: 'url', url }),
35
+ (error: unknown) => ({ kind: 'failed', error }),
36
+ );
37
+
38
+ /**
39
+ * @returns The logged-in customer's checkout URL. When the account can't give one, the failure is reported and the
40
+ * cart's own checkout URL is used, which still checks out (without the login).
41
+ */
42
+ async function accountCheckoutUrl(): Promise<string | null> {
43
+ if (!resolveAccountCheckout) return null;
44
+ try {
45
+ const url = await resolveAccountCheckout();
46
+ // Shopify's checkout shows the customer as logged in only with this parameter.
47
+ return url && `${url}${url.includes('?') ? '&' : '?'}logged_in=true`;
48
+ } catch (error) {
49
+ reportError(error, 'account checkout');
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /** @throws {TetherError} `ERROR` without a checkout URL (the cart is empty or Shopify no longer has it). */
55
+ async function checkoutUrl(cartId: string): Promise<string> {
56
+ const cartAnswer = askCheckoutUrl(cartId);
57
+ const accountUrl = await accountCheckoutUrl();
58
+ if (accountUrl) return accountUrl;
59
+ const answer = await cartAnswer;
60
+ if (answer.kind === 'failed') throw answer.error;
61
+ if (!answer.url) throw new TetherError('No checkoutUrl (cart empty or expired)');
62
+ return answer.url;
63
+ }
64
+
65
+ /**
66
+ * A new tab's `opener` gives the checkout page a handle on this one; a browser that doesn't let a script clear it
67
+ * leaves the tab as it opened.
68
+ */
69
+ function detachOpenerIfAllowed(tab: Window) {
70
+ try {
71
+ tab.opener = null;
72
+ } catch {
73
+ // The browser keeps `opener` read-only: see the function's comment.
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Goes to Shopify's checkout. With `_blank` the tab opens inside the click (Safari blocks `window.open` after an
79
+ * await) and gets its URL once there is one.
80
+ *
81
+ * @returns The checkout URL, `null` with nothing to check out or after a reported failure.
82
+ */
83
+ export async function checkout(target: CheckoutTarget = cfg.checkoutTarget): Promise<string | null> {
84
+ const cartId = savedCartId();
85
+ const cart = currentCart();
86
+ if (!cartId || (cart && !cart.totalQuantity)) return null;
87
+ const tab = target === '_blank' ? window.open('', '_blank') : null;
88
+ if (tab) detachOpenerIfAllowed(tab);
89
+ try {
90
+ const url = await checkoutUrl(cartId);
91
+ if (!isHttpsUrl(url)) throw new TetherError('The checkout URL is not https: not opened');
92
+ emit('checkout', { url, cart: currentCart() });
93
+ if (tab && !tab.closed) tab.location.href = url;
94
+ else window.location.href = url;
95
+ return url;
96
+ } catch (error) {
97
+ tab?.close();
98
+ reportError(error, 'checkout failed');
99
+ return null;
100
+ }
101
+ }
@@ -3,8 +3,8 @@
3
3
  * into the open cart, Tab stays inside it and focus returns on close (`cartFocus`). A page without a drawer (a cart
4
4
  * built in React, Svelte or Vue) still has an open state, for `Tether.cart.subscribe`.
5
5
  */
6
- import { cfg } from '../config.js';
7
- import { sel, get, qsa, emit } from '../dom.js';
6
+ import { cfg } from '../config.ts';
7
+ import { sel, get, qsa, emit } from '../dom.ts';
8
8
 
9
9
  const FOCUSABLE =
10
10
  'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
@@ -15,30 +15,22 @@ const FOCUS_AFTER_OPEN_MS = 60;
15
15
  /** The open state of a page without a drawer element. */
16
16
  let openWithoutDrawer = false;
17
17
 
18
- /** @type {HTMLElement | null} Where focus was before the cart opened. */
19
- let returnFocus = null;
18
+ /** Where focus was before the cart opened. */
19
+ let returnFocus: HTMLElement | null = null;
20
20
 
21
- /**
22
- * @param {Element | null} el
23
- * @returns {el is HTMLElement}
24
- */
25
- const isFocusable = (el) => el !== null && 'focus' in el;
21
+ const isFocusable = (el: Element | null): el is HTMLElement => el !== null && 'focus' in el;
26
22
 
27
- /**
28
- * @param {ParentNode} drawer
29
- * @returns {HTMLElement[]} The visible focusable elements inside `drawer`, in document order.
30
- */
31
- const focusables = (drawer) =>
23
+ /** @returns The visible focusable elements inside `drawer`, in document order. */
24
+ const focusables = (drawer: ParentNode): HTMLElement[] =>
32
25
  qsa(drawer, FOCUSABLE).filter((el) => el.offsetParent !== null || el === document.activeElement);
33
26
 
34
- /** @returns {HTMLElement[]} Every cart drawer on the page. */
35
- export const drawers = () => qsa(document, sel('cart-drawer'));
27
+ /** Every cart drawer on the page. */
28
+ export const drawers = (): HTMLElement[] => qsa(document, sel('cart-drawer'));
36
29
 
37
- /** @param {HTMLElement} drawer */
38
- const isOpenDrawer = (drawer) => drawer.classList.contains(cfg.cartDrawerOpenClass);
30
+ const isOpenDrawer = (drawer: HTMLElement) => drawer.classList.contains(cfg.cartDrawerOpenClass);
39
31
 
40
- /** @returns {boolean} True while a drawer, or the drawer-less cart, is open. */
41
- export function isOpen() {
32
+ /** True while a drawer, or the drawer-less cart, is open. */
33
+ export function isOpen(): boolean {
42
34
  const all = drawers();
43
35
  return all.length ? all.some(isOpenDrawer) : openWithoutDrawer;
44
36
  }
@@ -46,10 +38,8 @@ export function isOpen() {
46
38
  /**
47
39
  * `aria-expanded` on the open buttons, and dialog semantics on the drawers unless their markup has its own. A closed
48
40
  * 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
41
  */
52
- export function syncDrawerAria(open) {
42
+ export function syncDrawerAria(open: boolean): void {
53
43
  qsa(document, sel('cart-open')).forEach((button) => {
54
44
  button.setAttribute('aria-expanded', String(open));
55
45
  });
@@ -65,15 +55,14 @@ export function syncDrawerAria(open) {
65
55
  });
66
56
  }
67
57
 
68
- /** @param {HTMLElement} drawer */
69
- function focusInto(drawer) {
58
+ function focusInto(drawer: HTMLElement) {
70
59
  const target = focusables(drawer)[0] ?? drawer;
71
60
  if (target === drawer && !drawer.hasAttribute('tabindex')) drawer.tabIndex = -1;
72
61
  target.focus({ preventScroll: true });
73
62
  }
74
63
 
75
64
  /** Opens every drawer except `tether-cart-drawer="manual"` ones, which the site opens itself. */
76
- export function openCart() {
65
+ export function openCart(): void {
77
66
  if (isOpen()) return;
78
67
  openWithoutDrawer = true;
79
68
  const opened = drawers().filter((drawer) => get(drawer, 'cart-drawer') !== 'manual');
@@ -94,7 +83,7 @@ export function openCart() {
94
83
  emit('cart-toggled', { open: true });
95
84
  }
96
85
 
97
- export function closeCart() {
86
+ export function closeCart(): void {
98
87
  if (!isOpen()) return;
99
88
  openWithoutDrawer = false;
100
89
  const all = drawers();
@@ -111,7 +100,7 @@ export function closeCart() {
111
100
  emit('cart-toggled', { open: false });
112
101
  }
113
102
 
114
- export function toggleCart() {
103
+ export function toggleCart(): void {
115
104
  if (isOpen()) closeCart();
116
105
  else openCart();
117
106
  }
@@ -119,10 +108,8 @@ export function toggleCart() {
119
108
  /**
120
109
  * Keeps Tab inside the open drawer (`cartFocus`): from the last focusable element it wraps to the first, with Shift
121
110
  * 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
111
  */
125
- export function trapFocus(event) {
112
+ export function trapFocus(event: KeyboardEvent): void {
126
113
  if (event.key !== 'Tab' || !cfg.cartFocus || !isOpen()) return;
127
114
  const drawer = drawers().find(isOpenDrawer);
128
115
  if (!drawer) return;