@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.
- package/README.md +51 -109
- package/dist/esm/astro.js +1 -1
- package/dist/esm/chunks/{account-UIPWJQ3Y.js → account-W4FBRKXA.js} +1 -1
- package/dist/esm/chunks/{account-pages-6ROA6Y4V.js → account-pages-IM3OQTOP.js} +1 -1
- package/dist/esm/chunks/analytics-QDVOLZY3.js +1 -0
- package/dist/esm/chunks/{chunk-T44MUL4Y.js → chunk-AUVAN3KZ.js} +1 -1
- package/dist/esm/chunks/chunk-BMDYNMRC.js +1 -0
- package/dist/esm/chunks/chunk-CUL66KRA.js +1 -0
- package/dist/esm/chunks/{chunk-6OZJCAGZ.js → chunk-CVNRODH3.js} +1 -1
- package/dist/esm/chunks/{chunk-5Z5O3767.js → chunk-CXHPBT7N.js} +1 -1
- package/dist/esm/chunks/chunk-GF6JTI65.js +1 -0
- package/dist/esm/chunks/chunk-MRSDJPBC.js +1 -0
- package/dist/esm/chunks/chunk-NYDZPTLP.js +1 -0
- package/dist/esm/chunks/chunk-QDHLXXAZ.js +1 -0
- package/dist/esm/chunks/{chunk-4GCXM764.js → chunk-QUECUXHD.js} +1 -1
- package/dist/esm/chunks/chunk-RJY76ESL.js +1 -0
- package/dist/esm/chunks/chunk-RPI5C3EJ.js +1 -0
- package/dist/esm/chunks/{chunk-BFTZR4OB.js → chunk-YC4JXWRG.js} +2 -2
- package/dist/esm/chunks/{diagnose-3FEKRIEK.js → diagnose-3XHPZZNP.js} +1 -1
- package/dist/esm/chunks/extras-C724KKG6.js +1 -0
- package/dist/esm/chunks/{markets-ui-FD6NI2OG.js → markets-ui-WBWWQAGU.js} +1 -1
- package/dist/esm/chunks/{search-UJ73YBQ5.js → search-5NFIZBPJ.js} +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -2
- package/dist/tether.account-pages.js +13 -3
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +13 -3
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +18 -3
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +20 -5
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +52 -28
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +353 -288
- package/dist/tether.markets.js +31 -5
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +2 -2
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +18 -3
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/cart-id.d.ts +2 -0
- package/dist/types/src/cart/mutations.d.ts +6 -0
- package/dist/types/src/cart/store.d.ts +5 -2
- package/dist/types/src/cart.d.ts +3 -2
- package/dist/types/src/client.d.ts +61 -22
- package/dist/types/src/config.d.ts +6 -1
- package/dist/types/src/index.d.ts +2 -2
- package/dist/types/src/markets.d.ts +9 -2
- package/dist/types/src/product-shape.d.ts +3 -1
- package/dist/types/src/uri.d.ts +6 -0
- package/package.json +54 -6
- package/dist/esm/chunks/analytics-2V632IRV.js +0 -1
- package/dist/esm/chunks/chunk-56FCLJJD.js +0 -1
- package/dist/esm/chunks/chunk-A5SYCVQM.js +0 -1
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +0 -1
- package/dist/esm/chunks/chunk-C5JYQCWH.js +0 -1
- package/dist/esm/chunks/chunk-E5YO6274.js +0 -1
- package/dist/esm/chunks/chunk-MPDWM2NN.js +0 -1
- package/dist/esm/chunks/chunk-YLUULATY.js +0 -1
- package/dist/esm/chunks/extras-6POFBNOP.js +0 -1
- package/src/account/addresses-view.ts +0 -58
- package/src/account/answers.ts +0 -79
- package/src/account/markup.ts +0 -36
- package/src/account/operations.ts +0 -145
- package/src/account/order-lines.ts +0 -75
- package/src/account/order-list.ts +0 -107
- package/src/account/order-page.ts +0 -137
- package/src/account/page-answers.ts +0 -317
- package/src/account/page-events.ts +0 -253
- package/src/account/server-client.ts +0 -183
- package/src/account/subscriptions-view.ts +0 -115
- package/src/account-api.ts +0 -98
- package/src/account-marks.ts +0 -35
- package/src/account-pages.ts +0 -81
- package/src/account.ts +0 -284
- package/src/amounts.ts +0 -64
- package/src/analytics-shopify.ts +0 -246
- package/src/analytics.ts +0 -315
- package/src/api-version.ts +0 -79
- package/src/astro.ts +0 -272
- package/src/cart/cart-shape.ts +0 -25
- package/src/cart/checkout.ts +0 -101
- package/src/cart/drawer.ts +0 -125
- package/src/cart/mutations.ts +0 -237
- package/src/cart/render.ts +0 -266
- package/src/cart/store.ts +0 -279
- package/src/cart.ts +0 -345
- package/src/client.ts +0 -391
- package/src/collection.ts +0 -175
- package/src/config-sections.ts +0 -284
- package/src/config.ts +0 -480
- package/src/controls/cart-controls.ts +0 -123
- package/src/controls/discount.ts +0 -75
- package/src/controls/extras.ts +0 -54
- package/src/controls/navigation.ts +0 -77
- package/src/diagnose-markup.ts +0 -298
- package/src/diagnose.ts +0 -294
- package/src/dom.ts +0 -268
- package/src/extras.ts +0 -136
- package/src/fragments.ts +0 -261
- package/src/globals.d.ts +0 -15
- package/src/images.ts +0 -91
- package/src/index.ts +0 -415
- package/src/initial.ts +0 -101
- package/src/lazy-iife.ts +0 -197
- package/src/lazy.ts +0 -42
- package/src/loader.ts +0 -363
- package/src/market-codes.ts +0 -6
- package/src/markets-ui.ts +0 -227
- package/src/markets.ts +0 -246
- package/src/markup.ts +0 -127
- package/src/mounting.ts +0 -264
- package/src/page-market.ts +0 -110
- package/src/part-integrity.ts +0 -7
- package/src/parts/account-pages.ts +0 -7
- package/src/parts/account.ts +0 -7
- package/src/parts/analytics.ts +0 -7
- package/src/parts/diagnose.ts +0 -7
- package/src/parts/extras.ts +0 -7
- package/src/parts/markets.ts +0 -7
- package/src/parts/search.ts +0 -7
- package/src/presets/barba.ts +0 -194
- package/src/product-events.ts +0 -115
- package/src/product-render.ts +0 -218
- package/src/product-shape.ts +0 -49
- package/src/product-state.ts +0 -318
- package/src/product.ts +0 -308
- package/src/repeaters.ts +0 -161
- package/src/search.ts +0 -305
- package/src/selling-plans.ts +0 -145
- package/src/server.ts +0 -211
- package/src/shopify-values.ts +0 -91
- package/src/status.ts +0 -106
- package/src/storage.ts +0 -34
- package/src/store-html.ts +0 -359
- package/src/storefront/catalog.ts +0 -196
- package/src/storefront/json-ld.ts +0 -144
- 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
|
-
}
|