@occluze/tether 0.7.0 → 0.7.2

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 (139) hide show
  1. package/README.md +51 -109
  2. package/dist/esm/astro.js +1 -1
  3. package/dist/esm/chunks/{account-UIPWJQ3Y.js → account-W4FBRKXA.js} +1 -1
  4. package/dist/esm/chunks/{account-pages-6ROA6Y4V.js → account-pages-IM3OQTOP.js} +1 -1
  5. package/dist/esm/chunks/analytics-QDVOLZY3.js +1 -0
  6. package/dist/esm/chunks/{chunk-T44MUL4Y.js → chunk-AUVAN3KZ.js} +1 -1
  7. package/dist/esm/chunks/chunk-BMDYNMRC.js +1 -0
  8. package/dist/esm/chunks/chunk-CUL66KRA.js +1 -0
  9. package/dist/esm/chunks/{chunk-6OZJCAGZ.js → chunk-CVNRODH3.js} +1 -1
  10. package/dist/esm/chunks/{chunk-5Z5O3767.js → chunk-CXHPBT7N.js} +1 -1
  11. package/dist/esm/chunks/chunk-GF6JTI65.js +1 -0
  12. package/dist/esm/chunks/chunk-MRSDJPBC.js +1 -0
  13. package/dist/esm/chunks/chunk-NYDZPTLP.js +1 -0
  14. package/dist/esm/chunks/chunk-QDHLXXAZ.js +1 -0
  15. package/dist/esm/chunks/{chunk-4GCXM764.js → chunk-QUECUXHD.js} +1 -1
  16. package/dist/esm/chunks/chunk-RJY76ESL.js +1 -0
  17. package/dist/esm/chunks/chunk-RPI5C3EJ.js +1 -0
  18. package/dist/esm/chunks/{chunk-BFTZR4OB.js → chunk-YC4JXWRG.js} +2 -2
  19. package/dist/esm/chunks/{diagnose-3FEKRIEK.js → diagnose-3XHPZZNP.js} +1 -1
  20. package/dist/esm/chunks/extras-C724KKG6.js +1 -0
  21. package/dist/esm/chunks/{markets-ui-FD6NI2OG.js → markets-ui-WBWWQAGU.js} +1 -1
  22. package/dist/esm/chunks/{search-UJ73YBQ5.js → search-5NFIZBPJ.js} +1 -1
  23. package/dist/esm/index.js +1 -1
  24. package/dist/esm/server.js +2 -2
  25. package/dist/tether.account-pages.js +13 -3
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +13 -3
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +18 -3
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +20 -5
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +52 -28
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +353 -288
  36. package/dist/tether.markets.js +31 -5
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +2 -2
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +18 -3
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/cart-id.d.ts +2 -0
  43. package/dist/types/src/cart/mutations.d.ts +6 -0
  44. package/dist/types/src/cart/store.d.ts +5 -2
  45. package/dist/types/src/cart.d.ts +3 -2
  46. package/dist/types/src/client.d.ts +61 -22
  47. package/dist/types/src/config.d.ts +6 -1
  48. package/dist/types/src/index.d.ts +2 -2
  49. package/dist/types/src/markets.d.ts +9 -2
  50. package/dist/types/src/product-shape.d.ts +3 -1
  51. package/dist/types/src/uri.d.ts +6 -0
  52. package/package.json +54 -6
  53. package/dist/esm/chunks/analytics-2V632IRV.js +0 -1
  54. package/dist/esm/chunks/chunk-56FCLJJD.js +0 -1
  55. package/dist/esm/chunks/chunk-A5SYCVQM.js +0 -1
  56. package/dist/esm/chunks/chunk-BA4LQJJQ.js +0 -1
  57. package/dist/esm/chunks/chunk-C5JYQCWH.js +0 -1
  58. package/dist/esm/chunks/chunk-E5YO6274.js +0 -1
  59. package/dist/esm/chunks/chunk-MPDWM2NN.js +0 -1
  60. package/dist/esm/chunks/chunk-YLUULATY.js +0 -1
  61. package/dist/esm/chunks/extras-6POFBNOP.js +0 -1
  62. package/src/account/addresses-view.ts +0 -58
  63. package/src/account/answers.ts +0 -79
  64. package/src/account/markup.ts +0 -36
  65. package/src/account/operations.ts +0 -145
  66. package/src/account/order-lines.ts +0 -75
  67. package/src/account/order-list.ts +0 -107
  68. package/src/account/order-page.ts +0 -137
  69. package/src/account/page-answers.ts +0 -317
  70. package/src/account/page-events.ts +0 -253
  71. package/src/account/server-client.ts +0 -183
  72. package/src/account/subscriptions-view.ts +0 -115
  73. package/src/account-api.ts +0 -98
  74. package/src/account-marks.ts +0 -35
  75. package/src/account-pages.ts +0 -81
  76. package/src/account.ts +0 -284
  77. package/src/amounts.ts +0 -64
  78. package/src/analytics-shopify.ts +0 -246
  79. package/src/analytics.ts +0 -315
  80. package/src/api-version.ts +0 -79
  81. package/src/astro.ts +0 -272
  82. package/src/cart/cart-shape.ts +0 -25
  83. package/src/cart/checkout.ts +0 -101
  84. package/src/cart/drawer.ts +0 -125
  85. package/src/cart/mutations.ts +0 -237
  86. package/src/cart/render.ts +0 -266
  87. package/src/cart/store.ts +0 -279
  88. package/src/cart.ts +0 -345
  89. package/src/client.ts +0 -391
  90. package/src/collection.ts +0 -175
  91. package/src/config-sections.ts +0 -284
  92. package/src/config.ts +0 -480
  93. package/src/controls/cart-controls.ts +0 -123
  94. package/src/controls/discount.ts +0 -75
  95. package/src/controls/extras.ts +0 -54
  96. package/src/controls/navigation.ts +0 -77
  97. package/src/diagnose-markup.ts +0 -298
  98. package/src/diagnose.ts +0 -294
  99. package/src/dom.ts +0 -268
  100. package/src/extras.ts +0 -136
  101. package/src/fragments.ts +0 -261
  102. package/src/globals.d.ts +0 -15
  103. package/src/images.ts +0 -91
  104. package/src/index.ts +0 -415
  105. package/src/initial.ts +0 -101
  106. package/src/lazy-iife.ts +0 -197
  107. package/src/lazy.ts +0 -42
  108. package/src/loader.ts +0 -363
  109. package/src/market-codes.ts +0 -6
  110. package/src/markets-ui.ts +0 -227
  111. package/src/markets.ts +0 -246
  112. package/src/markup.ts +0 -127
  113. package/src/mounting.ts +0 -264
  114. package/src/page-market.ts +0 -110
  115. package/src/part-integrity.ts +0 -7
  116. package/src/parts/account-pages.ts +0 -7
  117. package/src/parts/account.ts +0 -7
  118. package/src/parts/analytics.ts +0 -7
  119. package/src/parts/diagnose.ts +0 -7
  120. package/src/parts/extras.ts +0 -7
  121. package/src/parts/markets.ts +0 -7
  122. package/src/parts/search.ts +0 -7
  123. package/src/presets/barba.ts +0 -194
  124. package/src/product-events.ts +0 -115
  125. package/src/product-render.ts +0 -218
  126. package/src/product-shape.ts +0 -49
  127. package/src/product-state.ts +0 -318
  128. package/src/product.ts +0 -308
  129. package/src/repeaters.ts +0 -161
  130. package/src/search.ts +0 -305
  131. package/src/selling-plans.ts +0 -145
  132. package/src/server.ts +0 -211
  133. package/src/shopify-values.ts +0 -91
  134. package/src/status.ts +0 -106
  135. package/src/storage.ts +0 -34
  136. package/src/store-html.ts +0 -359
  137. package/src/storefront/catalog.ts +0 -196
  138. package/src/storefront/json-ld.ts +0 -144
  139. package/src/variant-render.ts +0 -139
package/src/store-html.ts DELETED
@@ -1,359 +0,0 @@
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
- const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
34
-
35
- /** The elements kept, by local name. Any other element is replaced by its content, which is cleaned the same way. */
36
- const ALLOWED_ELEMENTS = new Set([
37
- ...['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'div', 'br', 'hr', 'blockquote', 'code', 'pre'],
38
- ...['strong', 'em', 'b', 'i', 'u', 's', 'small', 'sub', 'sup'],
39
- ...['ul', 'ol', 'li', 'dl', 'dt', 'dd'],
40
- ...['table', 'caption', 'colgroup', 'col', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td'],
41
- ...['a', 'img', 'picture', 'source', 'video', 'figure', 'figcaption', 'iframe'],
42
- ]);
43
- /** Elements whose content is code, styles or form state rather than text to show: removed with their content. */
44
- const REMOVED_WITH_CONTENT = new Set(['script', 'style', 'template', 'noscript', 'textarea', 'select', 'title']);
45
-
46
- /** Attributes any kept element may have, and every `aria-*` one (`isAllowedAttribute`). */
47
- const GLOBAL_ATTRIBUTES = new Set(['style', 'title', 'lang', 'dir', 'role', 'align', 'width', 'height']);
48
- /** Further attributes per element. */
49
- const ELEMENT_ATTRIBUTES = new Map([
50
- ['a', new Set(['href', 'target', 'rel', 'hreflang'])],
51
- ['img', new Set(['src', 'srcset', 'sizes', 'alt', 'loading', 'decoding'])],
52
- ['source', new Set(['src', 'srcset', 'sizes', 'type', 'media'])],
53
- ['video', new Set(['src', 'poster', 'controls', 'autoplay', 'muted', 'loop', 'playsinline', 'preload'])],
54
- ['iframe', new Set(['src', 'allow', 'allowfullscreen', 'frameborder', 'loading'])],
55
- ['blockquote', new Set(['cite'])],
56
- ['ol', new Set(['start', 'type', 'reversed'])],
57
- ['li', new Set(['value'])],
58
- ['table', new Set(['border', 'cellpadding', 'cellspacing', 'summary'])],
59
- ['col', new Set(['span'])],
60
- ['colgroup', new Set(['span'])],
61
- ['th', new Set(['colspan', 'rowspan', 'headers', 'scope', 'valign'])],
62
- ['td', new Set(['colspan', 'rowspan', 'headers', 'valign'])],
63
- ]);
64
- /** Attributes whose value is fetched or navigated to. */
65
- const URL_ATTRIBUTES = new Set(['href', 'src', 'poster', 'cite']);
66
- /** Schemes that run code when navigated to. */
67
- const SCRIPT_URL = /^(javascript|vbscript|data):/i;
68
- /** `data:` stays allowed for images, which only decode pixels. */
69
- const DATA_IMAGE = /^data:image\/(png|gif|jpeg|webp|avif);/i;
70
-
71
- /**
72
- * Style properties that take content out of the flow or move it from where the flow put it. `inset` and its logical
73
- * forms, the transform properties and the motion path (`offset-*`) are matched by prefix (`isOutOfPlace`). A
74
- * reflection (`-webkit-box-reflect`) paints a copy of the element beside it, as far away as the element is large.
75
- */
76
- const OUT_OF_PLACE_PROPERTIES = new Set([
77
- 'position',
78
- 'top',
79
- 'right',
80
- 'bottom',
81
- 'left',
82
- 'z-index',
83
- '-webkit-box-reflect',
84
- ]);
85
- const OUT_OF_PLACE_PREFIXES = ['inset', 'transform', 'translate', 'rotate', 'scale', 'offset'];
86
- /**
87
- * A length in viewport units (`100vw`, `50dvh`, `10vmax`) or container-query units (`100cqw`), which sizes content to
88
- * the window, not the description: outside a size container, a container-query unit is a small-viewport unit.
89
- */
90
- const VIEWPORT_LENGTH = /\d(?:[sld]?v|cq)(?:w|h|i|b|min|max)\b/i;
91
- /**
92
- * Style properties, without a `-webkit-` prefix, that paint beyond their element's box by as far as their numbers
93
- * say: outlines and their offset, shadows, `filter` (`drop-shadow()`, `blur()`), text decoration and its offset, text
94
- * stroke, and the outset of a border image or mask border, which a unitless number multiplies by the border width.
95
- * Browsers list a shorthand's longhands; the border image and mask border shorthands are checked whole where a
96
- * parser keeps them.
97
- */
98
- const PAINTS_BEYOND =
99
- /^(?:outline|box-shadow|text-shadow|filter|text-decoration|text-underline-offset|text-stroke)|^(?:border-image|mask-border|mask-box-image)(?:-outset)?$/;
100
- /** The longest length, in pixels, those properties may give. */
101
- const MAX_PAINT_PX = 40;
102
- /**
103
- * Properties on which a small negative value only tightens text, with the most a negative value may be in each unit;
104
- * one in another unit, or beyond the limit, is removed. Spacing stops at -0.25em (-4px at a 16px font), less than the
105
- * advance of a letter or a space, so text tightens without running back over what precedes it. An indent stops at
106
- * `MAX_PAINT_PX`, the reach allowed to anything that paints beyond its element.
107
- */
108
- const NEGATIVE_LIMITS = new Map<string, Partial<Record<string, number>>>([
109
- ['letter-spacing', { px: 4, em: 0.25 }],
110
- ['word-spacing', { px: 4, em: 0.25 }],
111
- ['text-indent', { px: MAX_PAINT_PX }],
112
- ]);
113
- /**
114
- * A number with its unit, if any, captured (`5000px`, `1.5em`, `5e3px`, `100`, `5000%`). Digits inside a word or a
115
- * hex colour (`#5000ff`) are not one.
116
- */
117
- const NUMBER = /(?<![\w#.-])[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?(%|[a-z]+)?/gi;
118
- /**
119
- * A function other than `drop-shadow()` and `blur()`, with no function inside it: colours, a filter's colour and
120
- * opacity changes and a border image's gradient, whose numbers paint nothing beyond the box. They are left out before
121
- * the numbers are checked.
122
- */
123
- const NON_LENGTH_FUNCTION = /(?<![\w-])(?!drop-shadow\(|blur\()[\w-]+\([^()]*\)/g;
124
- /**
125
- * Values whose lengths depend on more than the declaration (the page's custom properties, arithmetic, the
126
- * environment), so they can't be checked against `MAX_PAINT_PX`.
127
- */
128
- const COMPUTED_VALUE = /(?:var|calc|min|max|clamp|env|attr)\(/i;
129
- /** Style values that fetch a resource or run code (`expression()` in old Internet Explorer). */
130
- const FETCHING_VALUE = /url\(|expression\(|@import/i;
131
-
132
- /** Browsers ignore control characters and spaces in a URL's scheme (`java^Iscript:`), so the check does too. */
133
- // eslint-disable-next-line no-control-regex -- control characters are exactly what browsers skip in a scheme
134
- const IGNORED_IN_SCHEME = /[\u0000- ]/g;
135
-
136
- /**
137
- * An embed runs its own player script on its own origin, but can't navigate this page, open dialogs over it or submit
138
- * forms. `allow-same-origin` keeps the player's own storage and cookies, which YouTube and Vimeo need.
139
- */
140
- const IFRAME_SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-presentation';
141
- /**
142
- * The permissions an embed may ask the page to delegate: the ones video players and social embeds use. Camera,
143
- * microphone, location, payment and the rest are never delegated to store content.
144
- */
145
- const IFRAME_FEATURES = new Set([
146
- ...['autoplay', 'encrypted-media', 'picture-in-picture', 'fullscreen', 'clipboard-write'],
147
- ...['accelerometer', 'gyroscope', 'web-share'],
148
- ]);
149
- /** Players check which site embeds them (YouTube refuses to play without it): the origin is sent, never the path. */
150
- const IFRAME_REFERRER_POLICY = 'strict-origin-when-cross-origin';
151
- /**
152
- * The video hosts product descriptions embed, by the origin of their embed code: YouTube (and its cookie-less
153
- * domain), Vimeo, Loom and Wistia. Store staff choose the video these hosts play, not the page around it.
154
- */
155
- const EMBED_ORIGINS = [
156
- 'https://www.youtube.com',
157
- 'https://www.youtube-nocookie.com',
158
- 'https://player.vimeo.com',
159
- 'https://www.loom.com',
160
- 'https://fast.wistia.net',
161
- ];
162
-
163
- /** @param name An attribute name, lower case. */
164
- function isAllowedAttribute(tag: string, name: string): boolean {
165
- return GLOBAL_ATTRIBUTES.has(name) || name.startsWith('aria-') || (ELEMENT_ATTRIBUTES.get(tag)?.has(name) ?? false);
166
- }
167
-
168
- /** @returns False for a URL that would run script. */
169
- function isSafeUrl(tag: string, name: string, value: string): boolean {
170
- const url = value.replace(IGNORED_IN_SCHEME, '');
171
- if (!SCRIPT_URL.test(url)) return true;
172
- return tag === 'img' && name === 'src' && DATA_IMAGE.test(url);
173
- }
174
-
175
- /** @returns Null for text that is not a URL, which no check below lets through. */
176
- function urlOrNull(value: string, base?: string): URL | null {
177
- try {
178
- return new URL(value, base);
179
- } catch {
180
- return null;
181
- }
182
- }
183
-
184
- /** What cleaning needs to know about the page the fragment is for. */
185
- interface Page {
186
- /** The page's origin, `null` when it has none (`about:blank`). */
187
- origin: string | null;
188
- /** Relative URLs resolve against it, as they will once the fragment is inserted. */
189
- baseUrl: string;
190
- /** An element whose `style` parses declarations the way the browser applies them. */
191
- styleParser: HTMLElement;
192
- /** The origins an iframe may load from. */
193
- embedOrigins: ReadonlySet<string>;
194
- }
195
-
196
- function pageOf(document: Document, styleParser: HTMLElement, embedOrigins: readonly string[]): Page {
197
- const baseUrl = document.baseURI;
198
- const origin = urlOrNull(baseUrl)?.origin ?? 'null';
199
- return {
200
- origin: origin === 'null' ? null : origin,
201
- baseUrl,
202
- styleParser,
203
- embedOrigins: new Set([...EMBED_ORIGINS, ...embedOrigins]),
204
- };
205
- }
206
-
207
- /**
208
- * @param value An iframe's `src`.
209
- * @returns True for a URL on an allowed embed origin other than the page's: any other embed is removed.
210
- */
211
- function isAllowedEmbed(value: string, page: Page): boolean {
212
- const url = urlOrNull(value.trim(), page.baseUrl);
213
- return url !== null && url.origin !== page.origin && page.embedOrigins.has(url.origin);
214
- }
215
-
216
- /**
217
- * @param value An iframe's `allow` attribute: features separated by `;`, each with an optional allowlist.
218
- * @returns The features from `IFRAME_FEATURES` alone, each for the embed's own origin.
219
- */
220
- function allowedFeatures(value: string): string {
221
- const features = value.split(';').map((directive) => directive.trim().split(/\s+/)[0]?.toLowerCase() ?? '');
222
- return [...new Set(features.filter((feature) => IFRAME_FEATURES.has(feature)))].join('; ');
223
- }
224
-
225
- /**
226
- * @param property A style property name, lower case.
227
- * @returns True for a declaration that would put content anywhere but where the description's flow puts it.
228
- */
229
- function isOutOfPlace(property: string, value: string): boolean {
230
- if (OUT_OF_PLACE_PROPERTIES.has(property) || OUT_OF_PLACE_PREFIXES.some((prefix) => property.startsWith(prefix))) {
231
- return true;
232
- }
233
- if (VIEWPORT_LENGTH.test(value)) return true;
234
- const limits = NEGATIVE_LIMITS.get(property);
235
- if (limits) return COMPUTED_VALUE.test(value) || movesBackTooFar(value, limits);
236
- // A negative margin moves content back over what comes before. A minus sign is a negative length, or a `calc()`
237
- // that can be one.
238
- return property.startsWith('margin') && value.includes('-');
239
- }
240
-
241
- /** @param limits The most a negative number may be, by its unit (`NEGATIVE_LIMITS`). */
242
- function movesBackTooFar(value: string, limits: Partial<Record<string, number>>): boolean {
243
- return [...value.matchAll(NUMBER)].some(([number = '', unit = '']) => {
244
- const amount = parseFloat(number);
245
- if (amount >= 0) return false;
246
- const limit = limits[unit.toLowerCase()];
247
- return limit === undefined || -amount > limit;
248
- });
249
- }
250
-
251
- /**
252
- * @param property A style property name, lower case.
253
- * @returns True for a declaration that would paint farther beyond its element than `MAX_PAINT_PX`. Only pixel
254
- * lengths can be checked: any other non-zero number counts as too far, whether it has another unit (`em` grows with
255
- * the font size, which a description sets too), a percentage or none (an outset's multiple of the border width).
256
- */
257
- function paintsTooFar(property: string, value: string): boolean {
258
- if (!PAINTS_BEYOND.test(property.replace(/^-webkit-/, ''))) return false;
259
- if (COMPUTED_VALUE.test(value)) return true;
260
- return [...value.replace(NON_LENGTH_FUNCTION, '').matchAll(NUMBER)].some(([number = '', unit = '']) => {
261
- const amount = Math.abs(parseFloat(number));
262
- return amount !== 0 && (unit.toLowerCase() !== 'px' || amount > MAX_PAINT_PX);
263
- });
264
- }
265
-
266
- /**
267
- * An inline style as the browser's own parser reads it, without out-of-place, far-painting or fetching declarations. The parsed
268
- * form is written back, so text the parser skipped (an unknown or malformed declaration) is gone too.
269
- */
270
- function cleanStyle(value: string, styleParser: HTMLElement): string {
271
- const style = styleParser.style;
272
- style.cssText = value;
273
- const properties = Array.from({ length: style.length }, (_, index) => style.item(index));
274
- for (const property of properties) {
275
- const value = style.getPropertyValue(property);
276
- const remove = isOutOfPlace(property, value) || paintsTooFar(property, value) || FETCHING_VALUE.test(value);
277
- if (remove) style.removeProperty(property);
278
- }
279
- const cleaned = style.cssText;
280
- styleParser.removeAttribute('style');
281
- return cleaned;
282
- }
283
-
284
- /** @param element An allowed element: its other attributes are removed. */
285
- function cleanAttributes(element: Element, page: Page): void {
286
- const tag = element.localName;
287
- for (const attribute of [...element.attributes]) {
288
- const name = attribute.name.toLowerCase();
289
- const isUrl = URL_ATTRIBUTES.has(name);
290
- const keep = isAllowedAttribute(tag, name) && (!isUrl || isSafeUrl(tag, name, attribute.value));
291
- if (!keep) element.removeAttribute(attribute.name);
292
- }
293
- const style = element.getAttribute('style');
294
- if (style === null) return;
295
- const cleaned = cleanStyle(style, page.styleParser);
296
- if (cleaned === '') element.removeAttribute('style');
297
- else element.setAttribute('style', cleaned);
298
- }
299
-
300
- /** @param element A kept element, its attributes cleaned. */
301
- function hardenEmbedsAndLinks(element: Element): void {
302
- if (element.localName === 'iframe') {
303
- const allow = allowedFeatures(element.getAttribute('allow') ?? '');
304
- if (allow === '') element.removeAttribute('allow');
305
- else element.setAttribute('allow', allow);
306
- element.setAttribute('sandbox', IFRAME_SANDBOX);
307
- element.setAttribute('referrerpolicy', IFRAME_REFERRER_POLICY);
308
- return;
309
- }
310
- if (element.localName !== 'a' || element.getAttribute('target')?.toLowerCase() !== '_blank') return;
311
- // The opened page gets no handle on this one (`window.opener`) and isn't told which page linked to it.
312
- const rel = new Set((element.getAttribute('rel') ?? '').split(/\s+/).filter(Boolean));
313
- rel.add('noopener');
314
- rel.add('noreferrer');
315
- element.setAttribute('rel', [...rel].join(' '));
316
- }
317
-
318
- function cleanElement(element: Element, page: Page): void {
319
- const tag = element.localName;
320
- const isHtml = element.namespaceURI === HTML_NAMESPACE;
321
- if (isHtml && REMOVED_WITH_CONTENT.has(tag)) {
322
- element.remove();
323
- return;
324
- }
325
- if (!isHtml || !ALLOWED_ELEMENTS.has(tag)) {
326
- element.replaceWith(...element.childNodes);
327
- return;
328
- }
329
- if (tag === 'iframe' && !isAllowedEmbed(element.getAttribute('src') ?? '', page)) {
330
- element.remove();
331
- return;
332
- }
333
- cleanAttributes(element, page);
334
- hardenEmbedsAndLinks(element);
335
- }
336
-
337
- /**
338
- * Store HTML as a fragment of content elements only.
339
- *
340
- * @param document The document the fragment is for.
341
- * @param embedOrigins Origins iframes may load from besides the video players in `EMBED_ORIGINS`: the config's
342
- * `embedOrigins`, already checked to be https origins.
343
- */
344
- export function storeHtmlFragment(
345
- html: string | null | undefined,
346
- document: Document = globalThis.document,
347
- embedOrigins: readonly string[] = [],
348
- ): DocumentFragment {
349
- const template = document.createElement('template');
350
- // A <template>'s content is parsed inert: nothing in it loads or runs before it is cleaned.
351
- // eslint-disable-next-line no-restricted-syntax -- parsing into an inert template is the sanctioned way in
352
- template.innerHTML = html ?? '';
353
- // The template itself is never inserted, so its own `style` is a free parser for the content's declarations.
354
- const page = pageOf(document, template, embedOrigins);
355
- // Document order: a replaced element's content moves up and is visited after it; what a removed one held leaves
356
- // the fragment with it.
357
- for (const element of [...template.content.querySelectorAll('*')]) cleanElement(element, page);
358
- return template.content;
359
- }
@@ -1,196 +0,0 @@
1
- /**
2
- * The catalog reads of `createStorefront()`: products, collections and recommendations, with the browser script's own
3
- * product fragment, so what the server renders and hands over has the shape the script expects. Runs in Node and
4
- * Workers: no DOM, no shared config.
5
- */
6
- import { orNull, request, shaped } from '../client.ts';
7
- import { productFragment, collectionFields } from '../fragments.ts';
8
- import { productKey } from '../shopify-values.ts';
9
- import { PRODUCT, PRODUCT_NODE } from '../product-shape.ts';
10
- import { isCollectionAnswer, isRecommendationsAnswer, isSearchAnswer } from '../loader.ts';
11
- import type { DataCheck, StorefrontConfig } from '../client.ts';
12
- import type { ProductFragmentConfig, Product } from '../fragments.ts';
13
- import type { Collection, PageInfo, ProductPage } from '../loader.ts';
14
-
15
- /** What the catalog needs of a storefront's config. */
16
- type CatalogConfig = StorefrontConfig & ProductFragmentConfig;
17
-
18
- /** A page of a collection's products, or of a search. */
19
- export interface CatalogQuery {
20
- /** A collection handle; without one, a search. */
21
- collection?: string;
22
- /** Storefront search syntax; `null` or `''` is every product. */
23
- query?: string | null;
24
- /** Default the storefront's `pageSize`. */
25
- first?: number;
26
- /** The cursor of the page before. */
27
- after?: string | null;
28
- /** `ProductCollectionSortKeys` for a collection, `ProductSortKeys` for a search. */
29
- sortKey?: string;
30
- reverse?: boolean;
31
- /** `ProductFilter` values, for a collection. */
32
- filters?: unknown[];
33
- }
34
-
35
- type CollectionWithProducts = Collection & { products: Product[]; pageInfo: PageInfo };
36
- type RecommendationIntent = 'RELATED' | 'COMPLEMENTARY';
37
-
38
- /** A page of a GraphQL connection. */
39
- interface Connection<Node> {
40
- nodes: Node[];
41
- pageInfo: PageInfo;
42
- }
43
-
44
- /** Stops a paging loop on a store that keeps answering `hasNextPage` (500 pages of 25 is 12,500 products). */
45
- const MAX_PRODUCT_PAGES = 500;
46
- const COLLECTIONS_PER_PAGE = 100;
47
- const MAX_COLLECTION_PAGES = 100;
48
-
49
- const PRODUCT_BY_ID = `query TetherServerProductById($id: ID!) { node(id: $id) { ... on Product { ...TetherProduct } } }`;
50
- const PRODUCT_BY_HANDLE = `query TetherServerProduct($handle: String!) { product(handle: $handle) { ...TetherProduct } }`;
51
-
52
- const collectionQuery =
53
- () => `query TetherServerCollection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
54
- collection(handle: $handle) {
55
- ${collectionFields()}
56
- products(first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
57
- nodes { ...TetherProduct }
58
- pageInfo { hasNextPage endCursor }
59
- }
60
- }
61
- }`;
62
-
63
- const SEARCH_QUERY = `query TetherServerProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
64
- products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
65
- nodes { ...TetherProduct }
66
- pageInfo { hasNextPage endCursor }
67
- }
68
- }`;
69
-
70
- const collectionsQuery = () => `query TetherServerCollections($first: Int!, $after: String) {
71
- collections(first: $first, after: $after) { nodes { ${collectionFields()} } pageInfo { hasNextPage endCursor } }
72
- }`;
73
-
74
- const recommendationsQuery = (
75
- byId: boolean,
76
- ) => `query TetherServerRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
77
- productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
78
- }`;
79
-
80
- const isProductAnswer = shaped<{ product: Product | null }>({ product: orNull(PRODUCT) });
81
- const isNodeAnswer = shaped<{ node: Product | { id?: undefined } | null }>({ node: orNull(PRODUCT_NODE) });
82
- const isCollectionsAnswer = shaped<{ collections: Connection<Collection> }>({
83
- collections: { nodes: [{ handle: 'string' }], pageInfo: { hasNextPage: 'boolean' } },
84
- });
85
-
86
- async function allCollectionsOf(conf: StorefrontConfig): Promise<Collection[]> {
87
- const collections: Collection[] = [];
88
- let after: string | null = null;
89
- for (let page = 0; page < MAX_COLLECTION_PAGES; page++) {
90
- const data: { collections: Connection<Collection> } = await request(
91
- conf,
92
- collectionsQuery(),
93
- { first: COLLECTIONS_PER_PAGE, after },
94
- isCollectionsAnswer,
95
- );
96
- collections.push(...data.collections.nodes);
97
- if (!data.collections.pageInfo.hasNextPage) break;
98
- after = data.collections.pageInfo.endCursor;
99
- }
100
- return collections;
101
- }
102
-
103
- /** @param pageSize Products per page when a query doesn't say. */
104
- export function createCatalog(conf: CatalogConfig, pageSize: number) {
105
- /** @param operation The query, without the product fragment. */
106
- const send = <T>(operation: string, variables: Record<string, unknown>, isData: DataCheck<T>): Promise<T> =>
107
- request(conf, (access) => `${operation}\n${productFragment(conf, access)}`, variables, isData);
108
-
109
- /**
110
- * @param raw A handle, numeric id, gid or product URL.
111
- * @returns `null` when the store has no such product.
112
- */
113
- async function getProduct(raw: string | number | null | undefined): Promise<Product | null> {
114
- const key = productKey(raw);
115
- if (!key) return null;
116
- if (key.startsWith('gid://')) {
117
- const { node } = await send(PRODUCT_BY_ID, { id: key }, isNodeAnswer);
118
- return node?.id ? node : null;
119
- }
120
- const data = await send(PRODUCT_BY_HANDLE, { handle: key }, isProductAnswer);
121
- return data.product;
122
- }
123
-
124
- /** @returns No collection and no products for a handle the store doesn't have. */
125
- async function getCollectionPage(
126
- handle: string,
127
- { first = pageSize, after = null, sortKey, reverse = false, filters }: Omit<CatalogQuery, 'collection' | 'query'>,
128
- ): Promise<ProductPage> {
129
- const data = await send(
130
- collectionQuery(),
131
- {
132
- handle,
133
- first,
134
- after,
135
- sortKey: sortKey || undefined,
136
- reverse,
137
- filters: filters ?? undefined,
138
- },
139
- isCollectionAnswer,
140
- );
141
- if (!data.collection) return { collection: null, products: [], pageInfo: { hasNextPage: false, endCursor: null } };
142
- const { products, ...collection } = data.collection;
143
- return { collection, products: products.nodes, pageInfo: products.pageInfo };
144
- }
145
-
146
- async function getProducts(options: CatalogQuery = {}): Promise<ProductPage> {
147
- const { collection, query, first = pageSize, after = null, sortKey, reverse = false } = options;
148
- if (collection) return getCollectionPage(collection, options);
149
- const data = await send(
150
- SEARCH_QUERY,
151
- { first, after, query: query || undefined, sortKey: sortKey || undefined, reverse },
152
- isSearchAnswer,
153
- );
154
- return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
155
- }
156
-
157
- /** Every product of a collection or a search, page by page: for `getStaticPaths()`. */
158
- async function allProducts(options: Omit<CatalogQuery, 'first' | 'after'> = {}): Promise<Product[]> {
159
- const products: Product[] = [];
160
- let after: string | null = null;
161
- for (let page = 0; page < MAX_PRODUCT_PAGES; page++) {
162
- const result = await getProducts({ ...options, first: pageSize, after });
163
- products.push(...result.products);
164
- if (!result.pageInfo.hasNextPage) break;
165
- after = result.pageInfo.endCursor;
166
- }
167
- return products;
168
- }
169
-
170
- /** @returns `null` for a handle the store doesn't have. */
171
- async function getCollection(
172
- handle: string,
173
- options: Omit<CatalogQuery, 'collection'> = {},
174
- ): Promise<CollectionWithProducts | null> {
175
- const page = await getProducts({ ...options, collection: handle });
176
- return page.collection ? { ...page.collection, products: page.products, pageInfo: page.pageInfo } : null;
177
- }
178
-
179
- /** By product id (gid or numeric) or handle, so it can run next to `getProduct(handle)`. */
180
- async function getRecommendations(
181
- product: string | number | { id: string } | null | undefined,
182
- intent: RecommendationIntent = 'RELATED',
183
- ): Promise<Product[]> {
184
- const key = productKey(typeof product === 'object' ? product?.id : product);
185
- const byId = key?.startsWith('gid://') ?? false;
186
- const data = await send(
187
- recommendationsQuery(byId),
188
- byId ? { id: key, intent } : { handle: key, intent },
189
- isRecommendationsAnswer,
190
- );
191
- return data.productRecommendations ?? [];
192
- }
193
-
194
- const allCollections = () => allCollectionsOf(conf);
195
- return { getProduct, getProducts, allProducts, getCollection, allCollections, getRecommendations };
196
- }