@occluze/tether 0.6.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/README.md +11 -11
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
  4. package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
  5. package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
  6. package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
  7. package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
  8. package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
  9. package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
  10. package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
  11. package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
  12. package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
  13. package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
  14. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  15. package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
  16. package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
  17. package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
  18. package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
  19. package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
  20. package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
  21. package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -2
  24. package/dist/presets/barba.esm.js +19 -23
  25. package/dist/tether.account-pages.js +320 -365
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +242 -491
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +66 -88
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +36 -33
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +28 -36
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +1270 -894
  36. package/dist/tether.markets.js +73 -90
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +44 -68
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  43. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  44. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  45. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  46. package/dist/types/server/src/shared/json.d.ts +25 -0
  47. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  48. package/dist/types/src/account/addresses-view.d.ts +5 -0
  49. package/dist/types/src/account/answers.d.ts +40 -0
  50. package/dist/types/src/account/markup.d.ts +19 -0
  51. package/dist/types/src/account/operations.d.ts +41 -0
  52. package/dist/types/src/account/order-lines.d.ts +13 -0
  53. package/dist/types/src/account/order-list.d.ts +12 -0
  54. package/dist/types/src/account/order-page.d.ts +6 -0
  55. package/dist/types/src/account/page-answers.d.ts +182 -0
  56. package/dist/types/src/account/page-events.d.ts +2 -0
  57. package/dist/types/src/account/server-client.d.ts +60 -0
  58. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  59. package/dist/types/src/account-api.d.ts +18 -0
  60. package/dist/types/src/account-marks.d.ts +15 -0
  61. package/dist/types/src/account-pages.d.ts +8 -0
  62. package/dist/types/src/account.d.ts +64 -0
  63. package/dist/types/src/amounts.d.ts +12 -0
  64. package/dist/types/src/analytics-shopify.d.ts +41 -0
  65. package/dist/types/src/analytics.d.ts +2 -0
  66. package/dist/types/src/api-version.d.ts +28 -0
  67. package/dist/types/src/astro.d.ts +42 -0
  68. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  69. package/dist/types/src/cart/checkout.d.ts +16 -0
  70. package/dist/types/src/cart/drawer.d.ts +18 -0
  71. package/dist/types/src/cart/mutations.d.ts +79 -0
  72. package/dist/types/src/cart/render.d.ts +17 -0
  73. package/dist/types/src/cart/store.d.ts +182 -0
  74. package/dist/types/src/cart.d.ts +106 -0
  75. package/dist/types/src/client.d.ts +100 -0
  76. package/dist/types/src/collection.d.ts +25 -0
  77. package/dist/types/src/config-sections.d.ts +40 -0
  78. package/dist/types/src/config.d.ts +185 -0
  79. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  80. package/dist/types/src/controls/discount.d.ts +2 -0
  81. package/dist/types/src/controls/extras.d.ts +5 -0
  82. package/dist/types/src/controls/navigation.d.ts +12 -0
  83. package/dist/types/src/diagnose-markup.d.ts +19 -0
  84. package/dist/types/src/diagnose.d.ts +60 -0
  85. package/dist/types/src/dom.d.ts +75 -0
  86. package/dist/types/src/extras.d.ts +21 -0
  87. package/dist/types/src/fragments.d.ts +158 -0
  88. package/dist/types/src/globals.d.ts +15 -0
  89. package/dist/types/src/images.d.ts +20 -0
  90. package/dist/types/src/index.d.ts +167 -0
  91. package/dist/types/src/initial.d.ts +56 -0
  92. package/dist/types/src/lazy.d.ts +28 -0
  93. package/dist/types/src/loader.d.ts +84 -0
  94. package/dist/types/src/market-codes.d.ts +6 -0
  95. package/dist/types/src/markets-ui.d.ts +32 -0
  96. package/dist/types/src/markets.d.ts +72 -0
  97. package/dist/types/src/markup.d.ts +131 -0
  98. package/dist/types/src/mounting.d.ts +46 -0
  99. package/dist/types/src/page-market.d.ts +27 -0
  100. package/dist/types/src/presets/barba.d.ts +56 -0
  101. package/dist/types/src/product-events.d.ts +2 -0
  102. package/dist/types/src/product-render.d.ts +17 -0
  103. package/dist/types/src/product-shape.d.ts +88 -0
  104. package/dist/types/src/product-state.d.ts +110 -0
  105. package/dist/types/src/product.d.ts +66 -0
  106. package/dist/types/src/repeaters.d.ts +16 -0
  107. package/dist/types/src/search.d.ts +33 -0
  108. package/dist/types/src/selling-plans.d.ts +64 -0
  109. package/dist/types/src/server.d.ts +88 -0
  110. package/dist/types/src/shopify-values.d.ts +46 -0
  111. package/dist/types/src/status.d.ts +33 -0
  112. package/dist/types/src/storage.d.ts +12 -0
  113. package/dist/types/src/store-html.d.ts +39 -0
  114. package/dist/types/src/storefront/catalog.d.ts +38 -0
  115. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  116. package/dist/types/src/variant-render.d.ts +14 -0
  117. package/package.json +24 -23
  118. package/src/account/addresses-view.ts +58 -0
  119. package/src/account/answers.ts +79 -0
  120. package/src/account/markup.ts +36 -0
  121. package/src/account/operations.ts +145 -0
  122. package/src/account/{order-lines.js → order-lines.ts} +20 -41
  123. package/src/account/order-list.ts +107 -0
  124. package/src/account/order-page.ts +137 -0
  125. package/src/account/page-answers.ts +317 -0
  126. package/src/account/{page-events.js → page-events.ts} +55 -90
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/{account-pages.js → account-pages.ts} +29 -37
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
  135. package/src/analytics.ts +315 -0
  136. package/src/{api-version.js → api-version.ts} +25 -31
  137. package/src/{astro.js → astro.ts} +110 -104
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/{drawer.js → drawer.ts} +18 -31
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/{render.js → render.ts} +68 -96
  143. package/src/cart/store.ts +279 -0
  144. package/src/{cart.js → cart.ts} +82 -128
  145. package/src/client.ts +391 -0
  146. package/src/{collection.js → collection.ts} +30 -38
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
  150. package/src/controls/{discount.js → discount.ts} +13 -34
  151. package/src/controls/{extras.js → extras.ts} +15 -24
  152. package/src/controls/{navigation.js → navigation.ts} +24 -30
  153. package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
  154. package/src/{diagnose.js → diagnose.ts} +95 -96
  155. package/src/dom.ts +268 -0
  156. package/src/{extras.js → extras.ts} +43 -52
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/{images.js → images.ts} +30 -43
  160. package/src/index.ts +415 -0
  161. package/src/initial.ts +101 -0
  162. package/src/lazy-iife.ts +197 -0
  163. package/src/lazy.ts +42 -0
  164. package/src/{loader.js → loader.ts} +149 -123
  165. package/src/market-codes.ts +6 -0
  166. package/src/{markets-ui.js → markets-ui.ts} +91 -68
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/{mounting.js → mounting.ts} +88 -101
  170. package/src/{page-market.js → page-market.ts} +32 -38
  171. package/src/part-integrity.ts +7 -0
  172. package/src/parts/account-pages.ts +7 -0
  173. package/src/parts/account.ts +7 -0
  174. package/src/parts/analytics.ts +7 -0
  175. package/src/parts/diagnose.ts +7 -0
  176. package/src/parts/extras.ts +7 -0
  177. package/src/parts/markets.ts +7 -0
  178. package/src/parts/search.ts +7 -0
  179. package/src/presets/barba.ts +194 -0
  180. package/src/{product-events.js → product-events.ts} +33 -48
  181. package/src/{product-render.js → product-render.ts} +38 -77
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/{product.js → product.ts} +90 -126
  185. package/src/{repeaters.js → repeaters.ts} +40 -52
  186. package/src/{search.js → search.ts} +85 -103
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/{shopify-values.js → shopify-values.ts} +24 -22
  190. package/src/{status.js → status.ts} +34 -54
  191. package/src/storage.ts +34 -0
  192. package/src/store-html.ts +359 -0
  193. package/src/storefront/catalog.ts +196 -0
  194. package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
  195. package/src/{variant-render.js → variant-render.ts} +24 -50
  196. package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
  197. package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
  198. package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
  199. package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
  200. package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
  201. package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
  202. package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
  203. package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
  204. package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
  205. package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
  206. package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
  207. package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
  208. package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
  209. package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
  210. package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
  211. package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
  212. package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
  213. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
  214. package/dist/esm/chunks/search-OU75RNQL.js +0 -1
  215. package/src/account/addresses-view.js +0 -92
  216. package/src/account/browser-session.js +0 -359
  217. package/src/account/markup.js +0 -48
  218. package/src/account/operations.js +0 -180
  219. package/src/account/order-list.js +0 -91
  220. package/src/account/order-page.js +0 -192
  221. package/src/account/server-client.js +0 -172
  222. package/src/account/shared.js +0 -111
  223. package/src/account/shopify-auth.js +0 -150
  224. package/src/account-api.js +0 -118
  225. package/src/account-links.js +0 -35
  226. package/src/account.js +0 -342
  227. package/src/amounts.js +0 -36
  228. package/src/analytics.js +0 -352
  229. package/src/cart/checkout.js +0 -97
  230. package/src/cart/mutations.js +0 -234
  231. package/src/cart/store.js +0 -326
  232. package/src/client.js +0 -267
  233. package/src/config.js +0 -349
  234. package/src/dom.js +0 -287
  235. package/src/fragments.js +0 -237
  236. package/src/index.js +0 -353
  237. package/src/initial.js +0 -87
  238. package/src/lazy-iife.js +0 -116
  239. package/src/lazy.js +0 -39
  240. package/src/market-codes.js +0 -35
  241. package/src/markets.js +0 -334
  242. package/src/markup.js +0 -64
  243. package/src/parts/account-pages.js +0 -6
  244. package/src/parts/account.js +0 -6
  245. package/src/parts/analytics.js +0 -6
  246. package/src/parts/diagnose.js +0 -6
  247. package/src/parts/extras.js +0 -6
  248. package/src/parts/markets.js +0 -6
  249. package/src/parts/search.js +0 -6
  250. package/src/presets/barba.js +0 -190
  251. package/src/product-state.js +0 -318
  252. package/src/selling-plans.js +0 -154
  253. package/src/server.js +0 -215
  254. package/src/store-html.js +0 -0
  255. package/src/storefront/catalog.js +0 -199
  256. package/types/astro.d.ts +0 -28
  257. package/types/barba.d.ts +0 -16
  258. package/types/index.d.ts +0 -577
  259. package/types/server.d.ts +0 -182
@@ -1,27 +1,30 @@
1
1
  /**
2
- * Conversions of Shopify values that the browser script and the server helpers (`src/server.js`, `src/astro.js`)
2
+ * Conversions of Shopify values that the browser script and the server helpers (`src/server.ts`, `src/astro.ts`)
3
3
  * share, so a server-rendered page and the script that takes it over agree. Pure: no config, no DOM.
4
4
  */
5
5
 
6
- /** @typedef {{ amount: string, currencyCode: string }} Money A Storefront API `MoneyV2`. */
6
+ /** A Storefront API `MoneyV2`. */
7
+ export interface Money {
8
+ amount: string;
9
+ currencyCode: string;
10
+ }
7
11
 
8
12
  /** The `.env` names a store's domain and public Storefront token are read from, first match wins. */
9
13
  export const STORE_ENV_NAMES = {
10
14
  domain: ['PUBLIC_SHOPIFY_DOMAIN', 'PUBLIC_SHOPIFY_STORE_DOMAIN', 'SHOPIFY_DOMAIN'],
11
15
  token: ['PUBLIC_SHOPIFY_STOREFRONT_TOKEN', 'PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN', 'SHOPIFY_STOREFRONT_TOKEN'],
12
- };
16
+ } as const;
13
17
 
14
18
  /**
15
19
  * True for an absolute https URL. Links and redirects that come from Shopify (checkout, status pages,
16
20
  * tracking) are only used when this holds.
17
- *
18
- * @param {unknown} url
19
21
  */
20
- export function isHttpsUrl(url) {
22
+ export function isHttpsUrl(url: unknown): url is string {
21
23
  if (typeof url !== 'string') return false;
22
24
  try {
23
25
  return new URL(url).protocol === 'https:';
24
26
  } catch {
27
+ // Not an absolute URL, so not an https one.
25
28
  return false;
26
29
  }
27
30
  }
@@ -30,21 +33,18 @@ export function isHttpsUrl(url) {
30
33
  * True for a token Shopify issues as a secret (`shpat_` Admin, `shpss_` shared secret, `shpca_` client credentials,
31
34
  * `shppa_` private app, `shpua_` user). The public Storefront token has no such prefix. A secret token must never
32
35
  * reach a page.
33
- *
34
- * @param {string} token
35
36
  */
36
- export function isSecretShopifyToken(token) {
37
+ export function isSecretShopifyToken(token: string): boolean {
37
38
  return /^shp[a-z]{2}_/.test(token.trim());
38
39
  }
39
40
 
40
41
  /**
41
42
  * Money as text in `locale`, e.g. `{ amount: '30.0', currencyCode: 'EUR' }` in `nl-NL` → `€ 30,00`.
42
43
  *
43
- * @param {Money | null | undefined} money
44
- * @param {string | undefined} locale An `Intl` locale; the runtime's own when absent.
45
- * @returns {string} `''` without money or with an amount that isn't a number.
44
+ * @param locale An `Intl` locale; the runtime's own when absent.
45
+ * @returns `''` without money or with an amount that isn't a number.
46
46
  */
47
- export function formatMoney(money, locale) {
47
+ export function formatMoney(money: Money | null | undefined, locale: string | undefined): string {
48
48
  if (!money) return '';
49
49
  const amount = parseFloat(money.amount);
50
50
  if (Number.isNaN(amount)) return '';
@@ -62,13 +62,16 @@ export function formatMoney(money, locale) {
62
62
  * A product page's path. `productPath` is a folder (`/product` → `/product/<handle>`) or a template with `{handle}`
63
63
  * and `{id}` (the numeric product id), e.g. `/product?handle={handle}`.
64
64
  *
65
- * @param {string} productPath
66
- * @param {string | null | undefined} handle
67
- * @param {string | null | undefined} [id] A product gid or numeric id.
65
+ * @param id A product gid or numeric id.
66
+ * @returns `null` when the path needs a handle or an id the product lacks: no link beats a wrong one.
68
67
  */
69
- export function productUrl(productPath, handle, id) {
70
- if (!/\{(handle|id)\}/.test(productPath)) return `${productPath.replace(/\/$/, '')}/${handle ?? String(handle)}`;
71
- const numericId = id ? (id.split('/').pop() ?? '') : '';
68
+ export function productUrl(productPath: string, handle: string | null | undefined, id?: string | null): string | null {
69
+ if (!/\{(handle|id)\}/.test(productPath)) {
70
+ return handle ? `${productPath.replace(/\/$/, '')}/${encodeURIComponent(handle)}` : null;
71
+ }
72
+ const numericId = id ? (id.split('/').at(-1) ?? '') : '';
73
+ if (productPath.includes('{handle}') && !handle) return null;
74
+ if (productPath.includes('{id}') && !numericId) return null;
72
75
  return productPath
73
76
  .replace(/\{handle\}/g, encodeURIComponent(handle ?? ''))
74
77
  .replace(/\{id\}/g, encodeURIComponent(numericId));
@@ -77,10 +80,9 @@ export function productUrl(productPath, handle, id) {
77
80
  /**
78
81
  * The key Tether loads a product by: a handle, or a product gid.
79
82
  *
80
- * @param {string | number | null | undefined} raw A handle, a numeric id, a gid or a product URL (`…/products/<handle>`).
81
- * @returns {string | null}
83
+ * @param raw A handle, a numeric id, a gid or a product URL (`…/products/<handle>`).
82
84
  */
83
- export function productKey(raw) {
85
+ export function productKey(raw: string | number | null | undefined): string | null {
84
86
  const value = String(raw ?? '').trim();
85
87
  if (!value) return null;
86
88
  if (value.startsWith('gid://')) return value;
@@ -2,25 +2,23 @@
2
2
  * Where a product scope gets its key from, and the state of a scope (loading, ready, error, not found), so a failing
3
3
  * API never leaves a broken page.
4
4
  */
5
- import { attr, sel, get, scoped, show } from './dom.js';
5
+ import { attr, sel, get, scoped, show } from './dom.ts';
6
6
 
7
- /**
8
- * @typedef {object} ProductKeySource
9
- * @property {string | null} key The product to load; `null` when the attribute or the page URL has none.
10
- * @property {boolean} fromUrl The key comes from the page URL (`url:…`).
11
- * @property {string} raw The attribute's value as written.
12
- */
7
+ interface ProductKeySource {
8
+ /** The product to load; `null` when the attribute or the page URL has none. */
9
+ key: string | null;
10
+ /** The key comes from the page URL (`url:…`). */
11
+ fromUrl: boolean;
12
+ /** The attribute's value as written. */
13
+ raw: string;
14
+ }
13
15
 
14
- /** @typedef {'loading' | 'ready' | 'error' | 'not-found'} ScopeStatus */
16
+ type ScopeStatus = 'loading' | 'ready' | 'error' | 'not-found';
15
17
 
16
18
  const URL_SOURCE = 'url:';
17
19
 
18
- /**
19
- * A path segment as text. A malformed escape (`%E0%A4%A`) is kept as written, which is how the browser shows it.
20
- *
21
- * @param {string} segment
22
- */
23
- function decodePathSegment(segment) {
20
+ /** A path segment as text. A malformed escape (`%E0%A4%A`) is kept as written, which is how the browser shows it. */
21
+ function decodePathSegment(segment: string): string {
24
22
  try {
25
23
  return decodeURIComponent(segment);
26
24
  } catch (error) {
@@ -35,12 +33,13 @@ function decodePathSegment(segment) {
35
33
  * - `url:handle`: `?handle=` from the page URL (any parameter: `url:p` reads `?p=`);
36
34
  * - `url:path`: the last segment of the page path (`/shop/coffee-box` → `coffee-box`).
37
35
  *
38
- * @param {Element} el
39
- * @param {Pick<Location, 'pathname' | 'search'>} [location]
40
- * @param {string} [name] The attribute, without prefix.
41
- * @returns {ProductKeySource}
36
+ * @param name The attribute, without prefix.
42
37
  */
43
- export function resolveProductKey(el, location = window.location, name = 'product') {
38
+ export function resolveProductKey(
39
+ el: Element,
40
+ location: Pick<Location, 'pathname' | 'search'> = window.location,
41
+ name = 'product',
42
+ ): ProductKeySource {
44
43
  const raw = (get(el, name) ?? '').trim();
45
44
  if (!raw.toLowerCase().startsWith(URL_SOURCE)) return { key: raw || null, fromUrl: false, raw };
46
45
  const source = raw.slice(URL_SOURCE.length).trim();
@@ -55,43 +54,28 @@ export function resolveProductKey(el, location = window.location, name = 'produc
55
54
  return { key: key?.trim() || null, fromUrl: true, raw };
56
55
  }
57
56
 
58
- /** @type {readonly ScopeStatus[]} The values of `tether-show` that follow the scope's state. */
59
- export const STATUS_KEYS = ['loading', 'ready', 'error', 'not-found'];
57
+ /** The values of `tether-show` that follow the scope's state. */
58
+ export const STATUS_KEYS: readonly ScopeStatus[] = ['loading', 'ready', 'error', 'not-found'];
60
59
 
61
- /**
62
- * @param {unknown} value
63
- * @returns {value is ScopeStatus}
64
- */
65
- const isScopeStatus = (value) => STATUS_KEYS.some((status) => status === value);
60
+ const isScopeStatus = (value: unknown): value is ScopeStatus => STATUS_KEYS.some((status) => status === value);
66
61
 
67
- /**
68
- * @param {unknown} error
69
- * @returns {unknown} The error's `code` (a `TetherError` has one).
70
- */
71
- const codeOf = (error) => (typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined);
62
+ /** @returns The error's `code` (a `TetherError` has one). */
63
+ const codeOf = (error: unknown): unknown =>
64
+ typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
72
65
 
73
- /**
74
- * @param {unknown} error
75
- * @returns {boolean} True for a `TetherError` with code `NOT_FOUND`.
76
- */
77
- export const isNotFound = (error) => codeOf(error) === 'NOT_FOUND';
66
+ /** @returns True for a `TetherError` with code `NOT_FOUND`. */
67
+ export const isNotFound = (error: unknown): boolean => codeOf(error) === 'NOT_FOUND';
78
68
 
79
- /** @returns {string} The selector of every scope that owns its state elements; the nearest one wins. */
80
- export const boundary = () =>
69
+ /** @returns The selector of every scope that owns its state elements; the nearest one wins. */
70
+ export const boundary = (): string =>
81
71
  ['product', 'collection', 'products', 'recommendations'].map((name) => sel(name)).join(',');
82
72
 
83
- /**
84
- * @param {Element} el
85
- * @param {Element} root
86
- * @returns {boolean} True when `el` belongs to the scope `root`, not to a scope nested in it.
87
- */
88
- export const ownedBy = (el, root) => el === root || el.parentElement?.closest(boundary()) === root;
73
+ /** @returns True when `el` belongs to the scope `root`, not to a scope nested in it. */
74
+ export const ownedBy = (el: Element, root: Element): boolean =>
75
+ el === root || el.parentElement?.closest(boundary()) === root;
89
76
 
90
- /**
91
- * @param {unknown} error
92
- * @returns {string} The error's code for `tether-error`, `error` without one.
93
- */
94
- function errorCode(error) {
77
+ /** @returns The error's code for `tether-error`, `error` without one. */
78
+ function errorCode(error: unknown): string {
95
79
  const code = codeOf(error);
96
80
  return typeof code === 'string' && code ? code : 'error';
97
81
  }
@@ -100,12 +84,8 @@ function errorCode(error) {
100
84
  * Sets `tether-state` (loading | ready | error) and `tether-error` (not-found or an error code) on `root`, toggles
101
85
  * `tether-show="loading|ready|error|not-found"` inside it, and disables its add-to-cart buttons when there's no
102
86
  * product.
103
- *
104
- * @param {Element} root
105
- * @param {ScopeStatus} status
106
- * @param {unknown} [error]
107
87
  */
108
- export function setScopeStatus(root, status, error) {
88
+ export function setScopeStatus(root: Element, status: ScopeStatus, error?: unknown): void {
109
89
  const notFound = status === 'not-found';
110
90
  const failed = status === 'error' || notFound;
111
91
  root.setAttribute(attr('state'), notFound ? 'error' : status);
package/src/storage.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The browser's `localStorage` and `sessionStorage`, which may be blocked: accessing them throws in private mode, in
3
+ * sandboxed frames and where the visitor blocks site data, and writing throws when they are full. Tether then keeps
4
+ * nothing between pages: a read finds nothing, and a write or removal does nothing. Each call catches on its own, so a
5
+ * blocked area never stops a call on the other.
6
+ */
7
+
8
+ type StorageArea = 'localStorage' | 'sessionStorage';
9
+
10
+ /** @returns `null` when nothing is stored under `key`, or the storage is blocked. */
11
+ export function getStored(key: string, area: StorageArea = 'localStorage'): string | null {
12
+ try {
13
+ return window[area].getItem(key);
14
+ } catch {
15
+ // Blocked storage holds nothing: see the file's comment.
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export function setStored(key: string, value: string, area: StorageArea = 'localStorage'): void {
21
+ try {
22
+ window[area].setItem(key, value);
23
+ } catch {
24
+ // Blocked or full storage keeps nothing: see the file's comment.
25
+ }
26
+ }
27
+
28
+ export function removeStored(key: string, area: StorageArea = 'localStorage'): void {
29
+ try {
30
+ window[area].removeItem(key);
31
+ } catch {
32
+ // Blocked storage has nothing to remove: see the file's comment.
33
+ }
34
+ }
@@ -0,0 +1,359 @@
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
+ }