@occluze/tether 0.6.7 → 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 (250) 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 -1
  24. package/dist/presets/barba.esm.js +55 -39
  25. package/dist/tether.account-pages.js +781 -495
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +462 -381
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +299 -194
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +354 -262
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +82 -45
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3653 -2116
  36. package/dist/tether.markets.js +238 -150
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +3 -3
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +207 -136
  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 +42 -22
  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.ts +75 -0
  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.ts +253 -0
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/subscriptions-view.ts +115 -0
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/account-pages.ts +81 -0
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/analytics-shopify.ts +246 -0
  135. package/src/analytics.ts +315 -0
  136. package/src/api-version.ts +79 -0
  137. package/src/astro.ts +272 -0
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/drawer.ts +125 -0
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/render.ts +266 -0
  143. package/src/cart/store.ts +279 -0
  144. package/src/cart.ts +345 -0
  145. package/src/client.ts +391 -0
  146. package/src/collection.ts +175 -0
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/cart-controls.ts +123 -0
  150. package/src/controls/discount.ts +75 -0
  151. package/src/controls/extras.ts +54 -0
  152. package/src/controls/navigation.ts +77 -0
  153. package/src/diagnose-markup.ts +298 -0
  154. package/src/diagnose.ts +294 -0
  155. package/src/dom.ts +268 -0
  156. package/src/extras.ts +136 -0
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/images.ts +91 -0
  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.ts +363 -0
  165. package/src/market-codes.ts +6 -0
  166. package/src/markets-ui.ts +227 -0
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/mounting.ts +264 -0
  170. package/src/page-market.ts +110 -0
  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.ts +115 -0
  181. package/src/product-render.ts +218 -0
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/product.ts +308 -0
  185. package/src/repeaters.ts +161 -0
  186. package/src/search.ts +305 -0
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/shopify-values.ts +91 -0
  190. package/src/status.ts +106 -0
  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.ts +144 -0
  195. package/src/variant-render.ts +139 -0
  196. package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
  197. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  198. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  199. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  200. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  201. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  202. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  203. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  204. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  205. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  206. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  207. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  208. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  209. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  210. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  211. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  212. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
  213. package/src/account-pages.js +0 -318
  214. package/src/account.js +0 -385
  215. package/src/analytics.js +0 -232
  216. package/src/api-version.js +0 -35
  217. package/src/astro.js +0 -101
  218. package/src/cart.js +0 -559
  219. package/src/client.js +0 -131
  220. package/src/collection.js +0 -114
  221. package/src/config.js +0 -85
  222. package/src/diagnose.js +0 -246
  223. package/src/dom.js +0 -288
  224. package/src/extras.js +0 -63
  225. package/src/fragments.js +0 -144
  226. package/src/index.js +0 -395
  227. package/src/initial.js +0 -75
  228. package/src/lazy-iife.js +0 -46
  229. package/src/lazy.js +0 -18
  230. package/src/loader.js +0 -198
  231. package/src/markets-ui.js +0 -90
  232. package/src/markets.js +0 -133
  233. package/src/mounting.js +0 -154
  234. package/src/parts/account-pages.js +0 -3
  235. package/src/parts/account.js +0 -3
  236. package/src/parts/analytics.js +0 -3
  237. package/src/parts/diagnose.js +0 -3
  238. package/src/parts/extras.js +0 -3
  239. package/src/parts/markets.js +0 -3
  240. package/src/parts/search.js +0 -3
  241. package/src/presets/barba.js +0 -100
  242. package/src/product.js +0 -570
  243. package/src/search.js +0 -153
  244. package/src/selling-plans.js +0 -94
  245. package/src/server.js +0 -252
  246. package/src/status.js +0 -66
  247. package/types/astro.d.ts +0 -17
  248. package/types/barba.d.ts +0 -16
  249. package/types/index.d.ts +0 -426
  250. package/types/server.d.ts +0 -110
@@ -0,0 +1,39 @@
1
+ /**
2
+ * HTML written in the Shopify admin (product and collection descriptions), made safe to insert into the site. Store
3
+ * staff and apps that can edit products are not trusted to act on the storefront, where customer sessions live. Only
4
+ * elements and attributes that show content are kept (an allow-list, so markup nobody reviewed is never let in):
5
+ * text formatting, lists, tables, images, links and video embeds look as written, inline `style` included, because
6
+ * the Shopify editor writes its formatting there. Script, page-wide `<style>` and forms (which could post the
7
+ * visitor's input anywhere) are removed, and embeds run sandboxed.
8
+ *
9
+ * A description stays where it is written, so it can't be laid over the page as a fake login (a phishing form on the
10
+ * shop's own origin). Removed for that: `id` (it would clobber `window.<id>` and the site's own ids); `class`, which
11
+ * would let a description borrow the site's own overlay or modal classes; and every style that takes content out of
12
+ * the flow (`position`, `inset`, `z-index`), moves it from where the flow put it (transforms, motion paths, negative
13
+ * margins, and negative indents and spacing beyond the small values that tighten text, `NEGATIVE_LIMITS`), copies it
14
+ * elsewhere (reflections) or sizes it to the window (viewport and container-query units).
15
+ * Outlines, shadows, filters, border-image and mask-border outsets, text decoration and text stroke paint beyond their
16
+ * element, so they are kept only while every number they give is a pixel length of a few dozen pixels at most
17
+ * (`MAX_PAINT_PX`): enough to set off an image or a callout, too little to cover the page.
18
+ * Styles that fetch anything go too.
19
+ *
20
+ * A frame shows whatever its origin serves, sized as the description likes, so a frame from a page store staff can
21
+ * write would be that fake login. Frames are kept only from the video players in `EMBED_ORIGINS` and the origins the
22
+ * site adds (`embedOrigins`), never from the site's own origin, where a sandbox can be lifted from inside. What an
23
+ * allowed frame shows is up to its host: the host's player, and whatever that player lets its users add (a call to
24
+ * action, an email gate). An origin a site adds is trusted the same way.
25
+ *
26
+ * Not prevented: content wider than the description (a table or embed on a narrow screen, or a box given a large
27
+ * width in pixels) overflows beside it, as in any HTML, and a capped shadow or outline still paints just past it. Tether
28
+ * doesn't clip the element it fills (`contain: paint`, `overflow`), because that would cut off those wide tables and
29
+ * embeds, stop margins collapsing through it and make it contain floats, changing how ordinary descriptions look. A
30
+ * site that wants the clip sets it on that element in its own CSS.
31
+ */
32
+ /**
33
+ * Store HTML as a fragment of content elements only.
34
+ *
35
+ * @param document The document the fragment is for.
36
+ * @param embedOrigins Origins iframes may load from besides the video players in `EMBED_ORIGINS`: the config's
37
+ * `embedOrigins`, already checked to be https origins.
38
+ */
39
+ export declare function storeHtmlFragment(html: string | null | undefined, document?: Document, embedOrigins?: readonly string[]): DocumentFragment;
@@ -0,0 +1,38 @@
1
+ import type { StorefrontConfig } from '../client.js';
2
+ import type { ProductFragmentConfig, Product } from '../fragments.js';
3
+ import type { Collection, PageInfo, ProductPage } from '../loader.js';
4
+ /** What the catalog needs of a storefront's config. */
5
+ type CatalogConfig = StorefrontConfig & ProductFragmentConfig;
6
+ /** A page of a collection's products, or of a search. */
7
+ export interface CatalogQuery {
8
+ /** A collection handle; without one, a search. */
9
+ collection?: string;
10
+ /** Storefront search syntax; `null` or `''` is every product. */
11
+ query?: string | null;
12
+ /** Default the storefront's `pageSize`. */
13
+ first?: number;
14
+ /** The cursor of the page before. */
15
+ after?: string | null;
16
+ /** `ProductCollectionSortKeys` for a collection, `ProductSortKeys` for a search. */
17
+ sortKey?: string;
18
+ reverse?: boolean;
19
+ /** `ProductFilter` values, for a collection. */
20
+ filters?: unknown[];
21
+ }
22
+ type CollectionWithProducts = Collection & {
23
+ products: Product[];
24
+ pageInfo: PageInfo;
25
+ };
26
+ type RecommendationIntent = 'RELATED' | 'COMPLEMENTARY';
27
+ /** @param pageSize Products per page when a query doesn't say. */
28
+ export declare function createCatalog(conf: CatalogConfig, pageSize: number): {
29
+ getProduct: (raw: string | number | null | undefined) => Promise<Product | null>;
30
+ getProducts: (options?: CatalogQuery) => Promise<ProductPage>;
31
+ allProducts: (options?: Omit<CatalogQuery, "first" | "after">) => Promise<Product[]>;
32
+ getCollection: (handle: string, options?: Omit<CatalogQuery, "collection">) => Promise<CollectionWithProducts | null>;
33
+ allCollections: () => Promise<Collection[]>;
34
+ getRecommendations: (product: string | number | {
35
+ id: string;
36
+ } | null | undefined, intent?: RecommendationIntent) => Promise<Product[]>;
37
+ };
38
+ export {};
@@ -0,0 +1,20 @@
1
+ import type { Product } from '../fragments.js';
2
+ export interface JsonLdOptions {
3
+ /** The product page's absolute URL; default from the storefront's `siteUrl`. */
4
+ url?: string;
5
+ /** Default the product's vendor. */
6
+ brand?: string;
7
+ }
8
+ /** Where the product pages are. */
9
+ interface JsonLdSite {
10
+ productPath: string;
11
+ siteUrl: string | undefined;
12
+ }
13
+ /**
14
+ * Text for a `<script>` tag: `<` is escaped so product text can never close the tag, and U+2028 / U+2029 so older
15
+ * JavaScript parsers read it.
16
+ */
17
+ export declare const scriptJson: (value: unknown) => string;
18
+ /** @returns The schema.org object, `null` without a product. */
19
+ export declare function jsonLdData(product: Product | null | undefined, options: JsonLdOptions, site: JsonLdSite): Record<string, unknown> | null;
20
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { ProductView } from './product-state.js';
2
+ import type { RenderScope } from './product-render.js';
3
+ /**
4
+ * The element of an add-to-cart button that holds its label, with the label as written kept in
5
+ * `data-tether-label`, so mode-dependent texts (`tether-subscribe-text`, `tether-soldout-text`) can be undone.
6
+ */
7
+ export declare function addButtonLabel(button: HTMLElement): {
8
+ label: HTMLElement;
9
+ original: string;
10
+ };
11
+ /**
12
+ * Writes what follows the variant and purchase.
13
+ */
14
+ export declare function writeVariant(scope: RenderScope, view: ProductView): void;
package/package.json CHANGED
@@ -1,25 +1,25 @@
1
1
  {
2
2
  "name": "@occluze/tether",
3
- "version": "0.6.7",
3
+ "version": "0.7.0",
4
4
  "description": "Free, open-source alternative to Smootify and Storesynk (Shopyflow): Shopify products, cart and subscriptions in Webflow with HTML attributes. Optional Shopify to Webflow CMS sync.",
5
5
  "type": "module",
6
6
  "main": "dist/esm/index.js",
7
7
  "module": "dist/esm/index.js",
8
8
  "exports": {
9
9
  ".": {
10
- "types": "./types/index.d.ts",
10
+ "types": "./dist/types/src/index.d.ts",
11
11
  "default": "./dist/esm/index.js"
12
12
  },
13
13
  "./server": {
14
- "types": "./types/server.d.ts",
14
+ "types": "./dist/types/src/server.d.ts",
15
15
  "default": "./dist/esm/server.js"
16
16
  },
17
17
  "./astro": {
18
- "types": "./types/astro.d.ts",
18
+ "types": "./dist/types/src/astro.d.ts",
19
19
  "default": "./dist/esm/astro.js"
20
20
  },
21
21
  "./presets/barba": {
22
- "types": "./types/barba.d.ts",
22
+ "types": "./dist/types/src/presets/barba.d.ts",
23
23
  "default": "./dist/presets/barba.esm.js"
24
24
  },
25
25
  "./dist/*": "./dist/*"
@@ -28,29 +28,46 @@
28
28
  "dist",
29
29
  "src",
30
30
  "LICENSE",
31
- "README.md",
32
- "types/index.d.ts",
33
- "types/barba.d.ts",
34
- "types/server.d.ts",
35
- "types/astro.d.ts"
31
+ "README.md"
36
32
  ],
37
33
  "scripts": {
38
- "build": "node scripts/build.mjs",
39
- "dev": "node scripts/build.mjs --watch",
40
- "test": "node --test test/*.test.js server/test/*.test.js && npm run test:types",
41
- "smoke": "node scripts/smoke.mjs",
42
- "pretest": "node scripts/build.mjs",
43
- "presmoke": "node scripts/build.mjs",
34
+ "build": "node --experimental-strip-types scripts/build.ts",
35
+ "dev": "node --experimental-strip-types scripts/build.ts --watch",
36
+ "format": "prettier --write .",
37
+ "format:check": "prettier --check .",
38
+ "lint": "eslint .",
39
+ "typecheck": "tsc -b",
40
+ "knip": "knip",
41
+ "deps": "depcruise src server/src server/bin server/scripts scripts",
42
+ "check": "npm run format:check && npm run lint && npm run typecheck && npm run knip && npm run deps && npm test",
43
+ "prepare": "lefthook install",
44
+ "test": "node --experimental-strip-types --test test/*.test.js server/test/*.test.js && npm run test:types",
44
45
  "test:types": "tsc -p types/tsconfig.json",
45
- "release": "node scripts/release.mjs",
46
+ "smoke": "node --experimental-strip-types scripts/smoke.ts",
47
+ "pretest": "node --experimental-strip-types scripts/build.ts --out .test-dist",
48
+ "presmoke": "node --experimental-strip-types scripts/build.ts",
49
+ "release": "node --experimental-strip-types scripts/release.ts",
46
50
  "e2e:build": "npm run build && cd examples/astro && (test -f .env || cp .env.example .env) && npm install --no-audit --no-fund && npm install --no-save ../.. && npx astro build",
47
51
  "e2e": "npm run e2e:build && playwright test -c e2e/playwright.config.js"
48
52
  },
49
53
  "devDependencies": {
50
- "@playwright/test": "^1.64.1",
51
- "esbuild": "^0.25.0",
52
- "jsdom": "^26.0.0",
53
- "typescript": "^5.9.3"
54
+ "@eslint/js": "10.0.1",
55
+ "@playwright/test": "1.64.1",
56
+ "@tsconfig/strictest": "2.0.8",
57
+ "@types/jsdom": "21.1.7",
58
+ "@types/node": "24.19.2",
59
+ "dependency-cruiser": "18.7.2",
60
+ "esbuild": "0.25.12",
61
+ "eslint": "10.14.0",
62
+ "eslint-config-prettier": "10.1.8",
63
+ "globals": "17.14.0",
64
+ "jsdom": "26.1.0",
65
+ "knip": "6.44.1",
66
+ "lefthook": "2.2.2",
67
+ "prettier": "3.9.10",
68
+ "typescript": "npm:@typescript/typescript6@6.0.2",
69
+ "typescript-eslint": "8.72.1",
70
+ "yaml": "2.9.1"
54
71
  },
55
72
  "license": "MIT",
56
73
  "author": {
@@ -91,7 +108,7 @@
91
108
  "publishConfig": {
92
109
  "access": "public"
93
110
  },
94
- "types": "types/index.d.ts",
111
+ "types": "dist/types/src/index.d.ts",
95
112
  "peerDependencies": {
96
113
  "astro": ">=4"
97
114
  },
@@ -99,5 +116,8 @@
99
116
  "astro": {
100
117
  "optional": true
101
118
  }
119
+ },
120
+ "engines": {
121
+ "node": ">=22.18"
102
122
  }
103
123
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The customer's addresses (`tether-addresses`), with `tether-show="addresses-loading"`, `addresses-empty` and
3
+ * `addresses-error`. The last list rendered is kept, so an edit button can fill the address form from it.
4
+ */
5
+ import { reportError } from '../config.ts';
6
+ import { get, qsa, repeat, sel, setText } from '../dom.ts';
7
+ import { addressText, bindsIn, ownValue, showIn } from './markup.ts';
8
+ import { addresses } from './operations.ts';
9
+ import type { CustomerAddress } from './page-answers.ts';
10
+
11
+ let shown: readonly CustomerAddress[] = [];
12
+
13
+ /** @returns The address with this id in the list shown last. */
14
+ export const shownAddress = (id: string): CustomerAddress | undefined => shown.find((address) => address.id === id);
15
+
16
+ /** @returns The text of each `tether-address-bind` value. */
17
+ const addressTexts = (address: CustomerAddress): Record<string, string> => ({
18
+ formatted: addressText(address),
19
+ name: [address.firstName, address.lastName].filter(Boolean).join(' '),
20
+ company: address.company || '',
21
+ address1: address.address1 || '',
22
+ address2: address.address2 || '',
23
+ city: address.city || '',
24
+ zip: address.zip || '',
25
+ province: address.province || '',
26
+ country: address.country || '',
27
+ phone: address.phoneNumber || '',
28
+ });
29
+
30
+ function fillAddress(row: HTMLElement, address: CustomerAddress): void {
31
+ row.dataset['tetherAddress'] = address.id;
32
+ const texts = addressTexts(address);
33
+ bindsIn(row, 'address-bind').forEach((el) => {
34
+ const text = ownValue(texts, get(el, 'address-bind') ?? '');
35
+ if (text !== undefined) setText(el, text);
36
+ });
37
+ showIn(row, 'address-show', { default: address.isDefault, 'not-default': !address.isDefault });
38
+ }
39
+
40
+ /** Loads and renders the addresses. A failure is reported and shows `addresses-error`, never "no addresses yet". */
41
+ export async function renderAddresses(root: ParentNode): Promise<void> {
42
+ const boxes = qsa(root, sel('addresses'));
43
+ if (!boxes.length) return;
44
+ showIn(root, 'show', { 'addresses-loading': true, 'addresses-empty': false });
45
+ let list: CustomerAddress[];
46
+ try {
47
+ list = await addresses();
48
+ } catch (error) {
49
+ reportError(error, 'addresses');
50
+ showIn(root, 'show', { 'addresses-loading': false, 'addresses-error': true });
51
+ return;
52
+ }
53
+ shown = list;
54
+ showIn(root, 'show', { 'addresses-loading': false, 'addresses-empty': list.length === 0, 'addresses-error': false });
55
+ boxes.forEach((box) => {
56
+ repeat(box, list, fillAddress);
57
+ });
58
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The Worker's account answers as typed values. The Worker passes on what Shopify answers to its fixed documents, and
3
+ * each answer is read once, field by field. Every field a document selects is kept. One that is missing or of another
4
+ * type reads as `null` (a list as empty, a count as 0): a page with one blank field is better than no page. The ids
5
+ * the pages act on are required, and an answer without one is an error. This module reads what the account part
6
+ * shows (the customer, their orders through the reader the Worker shares, Shopify's `userErrors`); page-answers.ts
7
+ * reads the account pages' answers.
8
+ */
9
+ import { TetherError } from '../client.ts';
10
+ import type { UserError } from '../client.ts';
11
+ import { field, isRecord, listOf, textList, textOrNull } from '../../server/src/shared/json.ts';
12
+ import { readOrders } from '../../server/src/shared/account-orders.ts';
13
+ import type { AccountOrder } from '../../server/src/shared/account-orders.ts';
14
+
15
+ /** The logged-in customer. */
16
+ export interface AccountCustomer {
17
+ /** The customer's gid; left out when the Worker's `/me` doesn't send it. */
18
+ id?: string;
19
+ firstName: string | null;
20
+ lastName: string | null;
21
+ displayName: string | null;
22
+ emailAddress: { emailAddress: string } | null;
23
+ defaultAddress: { formatted: string[] } | null;
24
+ }
25
+
26
+ export const unexpected = (what: string): TetherError =>
27
+ new TetherError(`Customer accounts: the answer has no ${what}`);
28
+
29
+ /**
30
+ * @param what Named in the error.
31
+ * @throws {TetherError} Without an id.
32
+ */
33
+ export function requiredId(value: unknown, what: string): string {
34
+ const id = textOrNull(value);
35
+ if (!id) throw unexpected(what);
36
+ return id;
37
+ }
38
+
39
+ /** @param value The `customer` of the Worker's `/me`, which flattens the email and the default address. */
40
+ export function toAccountCustomer(value: unknown): AccountCustomer {
41
+ const customer = isRecord(value) ? value : {};
42
+ const id = textOrNull(customer['id']);
43
+ const email = textOrNull(customer['email']);
44
+ const address = customer['address'];
45
+ return {
46
+ ...(id === null ? {} : { id }),
47
+ firstName: textOrNull(customer['firstName']),
48
+ lastName: textOrNull(customer['lastName']),
49
+ displayName: textOrNull(customer['displayName']),
50
+ emailAddress: email === null ? null : { emailAddress: email },
51
+ defaultAddress: Array.isArray(address) ? { formatted: textList(address) } : null,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * @param value The `orders` of the Worker's `/orders`.
57
+ * @throws {TetherError} For an order without an id.
58
+ */
59
+ export function toAccountOrders(value: unknown): AccountOrder[] {
60
+ const reading = readOrders(value);
61
+ if (reading.kind === 'missing-id') throw unexpected('order id');
62
+ return reading.orders;
63
+ }
64
+
65
+ /** @returns `null` for an entry without a message, which has nothing to show. */
66
+ function toUserError(value: unknown): UserError | null {
67
+ const message = textOrNull(field(value, 'message'));
68
+ if (message === null) return null;
69
+ const fieldPath = field(value, 'field');
70
+ return {
71
+ message,
72
+ field: Array.isArray(fieldPath) ? textList(fieldPath) : null,
73
+ code: textOrNull(field(value, 'code')),
74
+ };
75
+ }
76
+
77
+ /** @param value Shopify's `userErrors`, as Shopify or the Worker sends them. */
78
+ export const toUserErrors = (value: unknown): UserError[] =>
79
+ listOf(value, toUserError).filter((error) => error !== null);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The account markup's shared vocabulary: `tether-*-show` conditions, binds on a template row (the row itself may
3
+ * carry one), and how Shopify's enum values and formatted addresses read on the page.
4
+ */
5
+ import { get, qsa, sel, show } from '../dom.ts';
6
+
7
+ /**
8
+ * Shows or hides each `name` element whose value is one of `conditions`' keys; other values are left alone.
9
+ *
10
+ * @param name `show`, `order-show`, `address-show`…
11
+ */
12
+ export function showIn(root: ParentNode, name: string, conditions: Readonly<Record<string, boolean>>): void {
13
+ qsa(root, sel(name)).forEach((el) => {
14
+ const key = get(el, name) ?? '';
15
+ if (Object.hasOwn(conditions, key)) show(el, conditions[key] === true);
16
+ });
17
+ }
18
+
19
+ /**
20
+ * @param row A copy of a repeater's template.
21
+ * @returns The `name` elements in `row`, and `row` itself when it carries `name`.
22
+ */
23
+ export const bindsIn = (row: HTMLElement, name: string): HTMLElement[] =>
24
+ qsa(row, sel(name)).concat(row.matches(sel(name)) ? [row] : []);
25
+
26
+ /** @returns A Shopify enum as text: `PARTIALLY_FULFILLED` → `partially fulfilled`. */
27
+ export const enumText = (value: string | null | undefined): string =>
28
+ value ? value.toLowerCase().replace(/_/g, ' ') : '';
29
+
30
+ /** @returns Shopify's formatted address on one line. */
31
+ export const addressText = (address: { formatted?: string[] | null } | null | undefined): string =>
32
+ (address?.formatted ?? []).join(', ');
33
+
34
+ /** A bind's value, looked up by the attribute's text: only the table's own keys, never `constructor` and the like. */
35
+ export const ownValue = <T>(values: Readonly<Record<string, T>>, key: string): T | undefined =>
36
+ Object.hasOwn(values, key) ? values[key] : undefined;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The account pages' operations: the Worker's fixed routes (shared/account-routes.ts), which check the input and run
3
+ * the documents with the customer's token. Each result is read into its typed shape (page-answers.ts), and a failure
4
+ * is a `TetherError`: `NOT_LOGGED_IN`, `INVALID` for input the Worker or Shopify refuses, `NETWORK`, and
5
+ * `HTTP_<status>` for a request that failed.
6
+ */
7
+ import { TetherError } from '../client.ts';
8
+ import { emit } from '../dom.ts';
9
+ import { operationRequest } from '../../server/src/shared/account-routes.ts';
10
+ import type {
11
+ AddressFields,
12
+ OperationInputs,
13
+ OperationName,
14
+ ReturnLine,
15
+ SubscriptionAction,
16
+ } from '../../server/src/shared/account-routes.ts';
17
+
18
+ export type { AddressFields, ReturnLine };
19
+ import {
20
+ toAccountAddress,
21
+ toCustomerAddresses,
22
+ toDeletedId,
23
+ toOrderDetail,
24
+ toProfile,
25
+ toReturn,
26
+ toSubscriptionChange,
27
+ toSubscriptionContracts,
28
+ } from './page-answers.ts';
29
+ import type {
30
+ AccountAddress,
31
+ CustomerAddress,
32
+ OpenedReturn,
33
+ OrderDetail,
34
+ Profile,
35
+ SubscriptionChange,
36
+ SubscriptionContract,
37
+ } from './page-answers.ts';
38
+ import type { ServerAnswer, ServerRequest } from './server-client.ts';
39
+ import type { AccountCustomer } from './answers.ts';
40
+
41
+ /** The account part's session, shared so both parts act as one customer. */
42
+ interface PagesContext {
43
+ serverFetch: (path: string, request?: ServerRequest) => Promise<ServerAnswer>;
44
+ customer: () => Promise<AccountCustomer | null>;
45
+ bindCustomer: (root: ParentNode) => Promise<void>;
46
+ /** Forgets the cached customer after a change of their data. */
47
+ reset: () => void;
48
+ }
49
+
50
+ /** `tether:account-updated`'s `what`. */
51
+ type ChangedData = 'profile' | 'addresses' | 'subscriptions' | 'orders';
52
+
53
+ let context: PagesContext | null = null;
54
+
55
+ /** Called by the account part once, when it loads this part. */
56
+ export function setup(shared: PagesContext): void {
57
+ context = shared;
58
+ }
59
+
60
+ /** @throws {Error} Before `setup`: this part is only ever loaded by the account part, which calls it first. */
61
+ export function pagesContext(): PagesContext {
62
+ if (!context) throw new Error('account-pages: loaded without the account part');
63
+ return context;
64
+ }
65
+
66
+ /**
67
+ * @returns The operation's result fields, read by the caller.
68
+ * @throws {TetherError} With one of the codes in the file's comment.
69
+ */
70
+ async function run<Name extends OperationName>(
71
+ name: Name,
72
+ input: OperationInputs[Name],
73
+ ): Promise<Record<string, unknown>> {
74
+ const { serverFetch } = pagesContext();
75
+ const { method, path } = operationRequest(name, input);
76
+ const answer = await serverFetch(path, method === 'POST' ? { method, body: input } : {});
77
+ if (answer.kind === 'logged-out') throw new TetherError('Not logged in', { code: 'NOT_LOGGED_IN', status: 401 });
78
+ return answer.data;
79
+ }
80
+
81
+ function changed(what: ChangedData): void {
82
+ pagesContext().reset();
83
+ emit('account-updated', { what });
84
+ }
85
+
86
+ /**
87
+ * One of the customer's orders, in full (lines, totals, addresses, fulfillments, returns).
88
+ *
89
+ * @param id A gid or the numeric order id.
90
+ * @returns `null` when the customer has no such order.
91
+ */
92
+ export async function order(id: string | number): Promise<OrderDetail | null> {
93
+ const result = await run('order', { id });
94
+ return toOrderDetail(result['order']);
95
+ }
96
+
97
+ export async function updateProfile({
98
+ firstName,
99
+ lastName,
100
+ }: { firstName?: string; lastName?: string } = {}): Promise<Profile> {
101
+ const result = await run('profile', { firstName, lastName });
102
+ changed('profile');
103
+ return toProfile(result['customer']);
104
+ }
105
+
106
+ /** @returns The customer's addresses, the default one marked `isDefault`. */
107
+ export async function addresses(): Promise<CustomerAddress[]> {
108
+ const result = await run('addresses', {});
109
+ return toCustomerAddresses(result['addresses']);
110
+ }
111
+
112
+ /** Creates an address, or updates the one with `id`; without fields, only makes it the default. */
113
+ export async function saveAddress(
114
+ address: AddressFields | null,
115
+ { id, isDefault }: { id?: string | undefined; isDefault?: boolean } = {},
116
+ ): Promise<AccountAddress> {
117
+ const result = await run('saveAddress', { id, address, isDefault });
118
+ changed('addresses');
119
+ return toAccountAddress(result['address']);
120
+ }
121
+
122
+ /** @returns The deleted address's id. */
123
+ export async function deleteAddress(id: string): Promise<string> {
124
+ const result = await run('deleteAddress', { id });
125
+ changed('addresses');
126
+ return toDeletedId(result['deleted']);
127
+ }
128
+
129
+ export async function subscriptions(): Promise<SubscriptionContract[]> {
130
+ const result = await run('subscriptions', {});
131
+ return toSubscriptionContracts(result['subscriptions']);
132
+ }
133
+
134
+ /** @returns The changed contract or billing cycle. */
135
+ export async function subscriptionAction(id: string | number, action: SubscriptionAction): Promise<SubscriptionChange> {
136
+ const result = await run('subscription', { id, action });
137
+ changed('subscriptions');
138
+ return toSubscriptionChange(result);
139
+ }
140
+
141
+ export async function requestReturn(orderId: string | number, lines: ReturnLine[]): Promise<OpenedReturn> {
142
+ const result = await run('requestReturn', { orderId, lines });
143
+ changed('orders');
144
+ return toReturn(result['return']);
145
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The products of an order (`tether-order-lines`), for the order list and the order page alike. The order page's
3
+ * lines also carry price, total, sku and what can be returned; the order list's document has none of those, so they
4
+ * render empty there, and its rows get no return controls.
5
+ */
6
+ import { formatMoney, get, qsa, repeat, sel, setImage, setText } from '../dom.ts';
7
+ import { bindsIn, ownValue, showIn } from './markup.ts';
8
+ import type { AccountOrderLine } from '../../server/src/shared/account-orders.ts';
9
+ import type { OrderDetailLine } from './page-answers.ts';
10
+
11
+ /** A line of the order list, or of the order page, which has the fields after `image` too. */
12
+ type OrderLine = AccountOrderLine &
13
+ Partial<Pick<OrderDetailLine, 'id' | 'name' | 'sku' | 'price' | 'totalPrice' | 'suggestedReturnReasonDefinitions'>>;
14
+ /** Line id → how many of it can still be returned. */
15
+ type Returnable = ReadonlyMap<string, number>;
16
+
17
+ /**
18
+ * @param returnable `null` in the order list, which knows nothing of returns.
19
+ * @returns The text of each `tether-order-line` value.
20
+ */
21
+ const lineTexts = (line: OrderLine, returnable: number | null): Record<string, string | undefined> => ({
22
+ title: line.title || line.name || '',
23
+ 'variant-title': line.variantTitle || '',
24
+ quantity: String(line.quantity),
25
+ price: formatMoney(line.price),
26
+ total: formatMoney(line.totalPrice),
27
+ sku: line.sku || '',
28
+ returnable: returnable === null ? undefined : String(returnable),
29
+ });
30
+
31
+ const isInput = (el: HTMLElement): el is HTMLInputElement => el.tagName === 'INPUT';
32
+
33
+ /** The return controls of one line: how many can be returned, and Shopify's suggested reasons. */
34
+ function fillReturnControls(row: HTMLElement, line: OrderLine & { id: string }, returnable: number): void {
35
+ showIn(row, 'order-line-show', { returnable: returnable > 0 });
36
+ qsa(row, sel('return-quantity')).forEach((input) => {
37
+ if (!isInput(input)) return;
38
+ input.max = String(returnable);
39
+ input.min = '0';
40
+ if (!input.value) input.value = '0';
41
+ input.disabled = returnable < 1;
42
+ });
43
+ qsa(row, `select${sel('return-reason')}`).forEach((select) => {
44
+ // A select keeps its options across renders of the same line: they are added once.
45
+ if (select.dataset['tetherFilled'] === line.id) return;
46
+ select.dataset['tetherFilled'] = line.id;
47
+ (line.suggestedReturnReasonDefinitions?.nodes ?? []).forEach((reason) => {
48
+ select.appendChild(new Option(reason.name ?? '', reason.id));
49
+ });
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Renders `lines` into a `tether-order-lines` repeater.
55
+ *
56
+ * @param returns `null` for the order list.
57
+ */
58
+ export function fillLines(box: HTMLElement, lines: readonly OrderLine[], returns: Returnable | null): void {
59
+ repeat(box, lines, (row, line) => {
60
+ const id = line.id;
61
+ const returnable = returns && id ? (returns.get(id) ?? 0) : null;
62
+ if (id) row.dataset['tetherLine'] = id;
63
+ const texts = lineTexts(line, returnable);
64
+ bindsIn(row, 'order-line').forEach((el) => {
65
+ const key = get(el, 'order-line') ?? '';
66
+ if (key === 'image') {
67
+ if (line.image) setImage(el, line.image.url, line.image.altText || line.title || '');
68
+ return;
69
+ }
70
+ const text = ownValue(texts, key);
71
+ if (text !== undefined) setText(el, text);
72
+ });
73
+ if (id && returnable !== null) fillReturnControls(row, { ...line, id }, returnable);
74
+ });
75
+ }