@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.
- package/README.md +11 -11
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
- package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
- package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
- package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
- package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
- package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
- package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
- package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
- package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
- package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
- package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
- package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
- package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
- package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
- package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
- package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
- package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
- package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -2
- package/dist/presets/barba.esm.js +19 -23
- package/dist/tether.account-pages.js +320 -365
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +242 -491
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +66 -88
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +36 -33
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +28 -36
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +1270 -894
- package/dist/tether.markets.js +73 -90
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +44 -68
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/account-orders.d.ts +44 -0
- package/dist/types/server/src/shared/account-routes.d.ts +153 -0
- package/dist/types/server/src/shared/api-versions.d.ts +13 -0
- package/dist/types/server/src/shared/error-message.d.ts +2 -0
- package/dist/types/server/src/shared/json.d.ts +25 -0
- package/dist/types/server/src/shared/market-codes.d.ts +16 -0
- package/dist/types/src/account/addresses-view.d.ts +5 -0
- package/dist/types/src/account/answers.d.ts +40 -0
- package/dist/types/src/account/markup.d.ts +19 -0
- package/dist/types/src/account/operations.d.ts +41 -0
- package/dist/types/src/account/order-lines.d.ts +13 -0
- package/dist/types/src/account/order-list.d.ts +12 -0
- package/dist/types/src/account/order-page.d.ts +6 -0
- package/dist/types/src/account/page-answers.d.ts +182 -0
- package/dist/types/src/account/page-events.d.ts +2 -0
- package/dist/types/src/account/server-client.d.ts +60 -0
- package/dist/types/src/account/subscriptions-view.d.ts +2 -0
- package/dist/types/src/account-api.d.ts +18 -0
- package/dist/types/src/account-marks.d.ts +15 -0
- package/dist/types/src/account-pages.d.ts +8 -0
- package/dist/types/src/account.d.ts +64 -0
- package/dist/types/src/amounts.d.ts +12 -0
- package/dist/types/src/analytics-shopify.d.ts +41 -0
- package/dist/types/src/analytics.d.ts +2 -0
- package/dist/types/src/api-version.d.ts +28 -0
- package/dist/types/src/astro.d.ts +42 -0
- package/dist/types/src/cart/cart-shape.d.ts +43 -0
- package/dist/types/src/cart/checkout.d.ts +16 -0
- package/dist/types/src/cart/drawer.d.ts +18 -0
- package/dist/types/src/cart/mutations.d.ts +79 -0
- package/dist/types/src/cart/render.d.ts +17 -0
- package/dist/types/src/cart/store.d.ts +182 -0
- package/dist/types/src/cart.d.ts +106 -0
- package/dist/types/src/client.d.ts +100 -0
- package/dist/types/src/collection.d.ts +25 -0
- package/dist/types/src/config-sections.d.ts +40 -0
- package/dist/types/src/config.d.ts +185 -0
- package/dist/types/src/controls/cart-controls.d.ts +2 -0
- package/dist/types/src/controls/discount.d.ts +2 -0
- package/dist/types/src/controls/extras.d.ts +5 -0
- package/dist/types/src/controls/navigation.d.ts +12 -0
- package/dist/types/src/diagnose-markup.d.ts +19 -0
- package/dist/types/src/diagnose.d.ts +60 -0
- package/dist/types/src/dom.d.ts +75 -0
- package/dist/types/src/extras.d.ts +21 -0
- package/dist/types/src/fragments.d.ts +158 -0
- package/dist/types/src/globals.d.ts +15 -0
- package/dist/types/src/images.d.ts +20 -0
- package/dist/types/src/index.d.ts +167 -0
- package/dist/types/src/initial.d.ts +56 -0
- package/dist/types/src/lazy.d.ts +28 -0
- package/dist/types/src/loader.d.ts +84 -0
- package/dist/types/src/market-codes.d.ts +6 -0
- package/dist/types/src/markets-ui.d.ts +32 -0
- package/dist/types/src/markets.d.ts +72 -0
- package/dist/types/src/markup.d.ts +131 -0
- package/dist/types/src/mounting.d.ts +46 -0
- package/dist/types/src/page-market.d.ts +27 -0
- package/dist/types/src/presets/barba.d.ts +56 -0
- package/dist/types/src/product-events.d.ts +2 -0
- package/dist/types/src/product-render.d.ts +17 -0
- package/dist/types/src/product-shape.d.ts +88 -0
- package/dist/types/src/product-state.d.ts +110 -0
- package/dist/types/src/product.d.ts +66 -0
- package/dist/types/src/repeaters.d.ts +16 -0
- package/dist/types/src/search.d.ts +33 -0
- package/dist/types/src/selling-plans.d.ts +64 -0
- package/dist/types/src/server.d.ts +88 -0
- package/dist/types/src/shopify-values.d.ts +46 -0
- package/dist/types/src/status.d.ts +33 -0
- package/dist/types/src/storage.d.ts +12 -0
- package/dist/types/src/store-html.d.ts +39 -0
- package/dist/types/src/storefront/catalog.d.ts +38 -0
- package/dist/types/src/storefront/json-ld.d.ts +20 -0
- package/dist/types/src/variant-render.d.ts +14 -0
- package/package.json +24 -23
- package/src/account/addresses-view.ts +58 -0
- package/src/account/answers.ts +79 -0
- package/src/account/markup.ts +36 -0
- package/src/account/operations.ts +145 -0
- package/src/account/{order-lines.js → order-lines.ts} +20 -41
- package/src/account/order-list.ts +107 -0
- package/src/account/order-page.ts +137 -0
- package/src/account/page-answers.ts +317 -0
- package/src/account/{page-events.js → page-events.ts} +55 -90
- package/src/account/server-client.ts +183 -0
- package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
- package/src/account-api.ts +98 -0
- package/src/account-marks.ts +35 -0
- package/src/{account-pages.js → account-pages.ts} +29 -37
- package/src/account.ts +284 -0
- package/src/amounts.ts +64 -0
- package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
- package/src/analytics.ts +315 -0
- package/src/{api-version.js → api-version.ts} +25 -31
- package/src/{astro.js → astro.ts} +110 -104
- package/src/cart/cart-shape.ts +25 -0
- package/src/cart/checkout.ts +101 -0
- package/src/cart/{drawer.js → drawer.ts} +18 -31
- package/src/cart/mutations.ts +237 -0
- package/src/cart/{render.js → render.ts} +68 -96
- package/src/cart/store.ts +279 -0
- package/src/{cart.js → cart.ts} +82 -128
- package/src/client.ts +391 -0
- package/src/{collection.js → collection.ts} +30 -38
- package/src/config-sections.ts +284 -0
- package/src/config.ts +480 -0
- package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
- package/src/controls/{discount.js → discount.ts} +13 -34
- package/src/controls/{extras.js → extras.ts} +15 -24
- package/src/controls/{navigation.js → navigation.ts} +24 -30
- package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
- package/src/{diagnose.js → diagnose.ts} +95 -96
- package/src/dom.ts +268 -0
- package/src/{extras.js → extras.ts} +43 -52
- package/src/fragments.ts +261 -0
- package/src/globals.d.ts +15 -0
- package/src/{images.js → images.ts} +30 -43
- package/src/index.ts +415 -0
- package/src/initial.ts +101 -0
- package/src/lazy-iife.ts +197 -0
- package/src/lazy.ts +42 -0
- package/src/{loader.js → loader.ts} +149 -123
- package/src/market-codes.ts +6 -0
- package/src/{markets-ui.js → markets-ui.ts} +91 -68
- package/src/markets.ts +246 -0
- package/src/markup.ts +127 -0
- package/src/{mounting.js → mounting.ts} +88 -101
- package/src/{page-market.js → page-market.ts} +32 -38
- package/src/part-integrity.ts +7 -0
- package/src/parts/account-pages.ts +7 -0
- package/src/parts/account.ts +7 -0
- package/src/parts/analytics.ts +7 -0
- package/src/parts/diagnose.ts +7 -0
- package/src/parts/extras.ts +7 -0
- package/src/parts/markets.ts +7 -0
- package/src/parts/search.ts +7 -0
- package/src/presets/barba.ts +194 -0
- package/src/{product-events.js → product-events.ts} +33 -48
- package/src/{product-render.js → product-render.ts} +38 -77
- package/src/product-shape.ts +49 -0
- package/src/product-state.ts +318 -0
- package/src/{product.js → product.ts} +90 -126
- package/src/{repeaters.js → repeaters.ts} +40 -52
- package/src/{search.js → search.ts} +85 -103
- package/src/selling-plans.ts +145 -0
- package/src/server.ts +211 -0
- package/src/{shopify-values.js → shopify-values.ts} +24 -22
- package/src/{status.js → status.ts} +34 -54
- package/src/storage.ts +34 -0
- package/src/store-html.ts +359 -0
- package/src/storefront/catalog.ts +196 -0
- package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
- package/src/{variant-render.js → variant-render.ts} +24 -50
- package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
- package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
- package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
- package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
- package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
- package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
- package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
- package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
- package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
- package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
- package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
- package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
- package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
- package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
- package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
- package/dist/esm/chunks/search-OU75RNQL.js +0 -1
- package/src/account/addresses-view.js +0 -92
- package/src/account/browser-session.js +0 -359
- package/src/account/markup.js +0 -48
- package/src/account/operations.js +0 -180
- package/src/account/order-list.js +0 -91
- package/src/account/order-page.js +0 -192
- package/src/account/server-client.js +0 -172
- package/src/account/shared.js +0 -111
- package/src/account/shopify-auth.js +0 -150
- package/src/account-api.js +0 -118
- package/src/account-links.js +0 -35
- package/src/account.js +0 -342
- package/src/amounts.js +0 -36
- package/src/analytics.js +0 -352
- package/src/cart/checkout.js +0 -97
- package/src/cart/mutations.js +0 -234
- package/src/cart/store.js +0 -326
- package/src/client.js +0 -267
- package/src/config.js +0 -349
- package/src/dom.js +0 -287
- package/src/fragments.js +0 -237
- package/src/index.js +0 -353
- package/src/initial.js +0 -87
- package/src/lazy-iife.js +0 -116
- package/src/lazy.js +0 -39
- package/src/market-codes.js +0 -35
- package/src/markets.js +0 -334
- package/src/markup.js +0 -64
- package/src/parts/account-pages.js +0 -6
- package/src/parts/account.js +0 -6
- package/src/parts/analytics.js +0 -6
- package/src/parts/diagnose.js +0 -6
- package/src/parts/extras.js +0 -6
- package/src/parts/markets.js +0 -6
- package/src/parts/search.js +0 -6
- package/src/presets/barba.js +0 -190
- package/src/product-state.js +0 -318
- package/src/selling-plans.js +0 -154
- package/src/server.js +0 -215
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +0 -199
- package/types/astro.d.ts +0 -28
- package/types/barba.d.ts +0 -16
- package/types/index.d.ts +0 -577
- 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.
|
|
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
|
-
/**
|
|
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
|
|
44
|
-
* @
|
|
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
|
|
66
|
-
* @
|
|
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))
|
|
71
|
-
|
|
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
|
|
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.
|
|
5
|
+
import { attr, sel, get, scoped, show } from './dom.ts';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
16
|
+
type ScopeStatus = 'loading' | 'ready' | 'error' | 'not-found';
|
|
15
17
|
|
|
16
18
|
const URL_SOURCE = 'url:';
|
|
17
19
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
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
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
+
}
|