@occluze/tether 0.6.7 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -1
- package/dist/presets/barba.esm.js +55 -39
- package/dist/tether.account-pages.js +781 -495
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +462 -381
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +299 -194
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +354 -262
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +82 -45
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3653 -2116
- package/dist/tether.markets.js +238 -150
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +3 -3
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +207 -136
- 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 +42 -22
- 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.ts +75 -0
- 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.ts +253 -0
- package/src/account/server-client.ts +183 -0
- package/src/account/subscriptions-view.ts +115 -0
- package/src/account-api.ts +98 -0
- package/src/account-marks.ts +35 -0
- package/src/account-pages.ts +81 -0
- package/src/account.ts +284 -0
- package/src/amounts.ts +64 -0
- package/src/analytics-shopify.ts +246 -0
- package/src/analytics.ts +315 -0
- package/src/api-version.ts +79 -0
- package/src/astro.ts +272 -0
- package/src/cart/cart-shape.ts +25 -0
- package/src/cart/checkout.ts +101 -0
- package/src/cart/drawer.ts +125 -0
- package/src/cart/mutations.ts +237 -0
- package/src/cart/render.ts +266 -0
- package/src/cart/store.ts +279 -0
- package/src/cart.ts +345 -0
- package/src/client.ts +391 -0
- package/src/collection.ts +175 -0
- package/src/config-sections.ts +284 -0
- package/src/config.ts +480 -0
- package/src/controls/cart-controls.ts +123 -0
- package/src/controls/discount.ts +75 -0
- package/src/controls/extras.ts +54 -0
- package/src/controls/navigation.ts +77 -0
- package/src/diagnose-markup.ts +298 -0
- package/src/diagnose.ts +294 -0
- package/src/dom.ts +268 -0
- package/src/extras.ts +136 -0
- package/src/fragments.ts +261 -0
- package/src/globals.d.ts +15 -0
- package/src/images.ts +91 -0
- 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.ts +363 -0
- package/src/market-codes.ts +6 -0
- package/src/markets-ui.ts +227 -0
- package/src/markets.ts +246 -0
- package/src/markup.ts +127 -0
- package/src/mounting.ts +264 -0
- package/src/page-market.ts +110 -0
- 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.ts +115 -0
- package/src/product-render.ts +218 -0
- package/src/product-shape.ts +49 -0
- package/src/product-state.ts +318 -0
- package/src/product.ts +308 -0
- package/src/repeaters.ts +161 -0
- package/src/search.ts +305 -0
- package/src/selling-plans.ts +145 -0
- package/src/server.ts +211 -0
- package/src/shopify-values.ts +91 -0
- package/src/status.ts +106 -0
- 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.ts +144 -0
- package/src/variant-render.ts +139 -0
- package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
- package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
- package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
- package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
- package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
- package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
- package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
- package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
- package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
- package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
- package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
- package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
- package/src/account-pages.js +0 -318
- package/src/account.js +0 -385
- package/src/analytics.js +0 -232
- package/src/api-version.js +0 -35
- package/src/astro.js +0 -101
- package/src/cart.js +0 -559
- package/src/client.js +0 -131
- package/src/collection.js +0 -114
- package/src/config.js +0 -85
- package/src/diagnose.js +0 -246
- package/src/dom.js +0 -288
- package/src/extras.js +0 -63
- package/src/fragments.js +0 -144
- package/src/index.js +0 -395
- package/src/initial.js +0 -75
- package/src/lazy-iife.js +0 -46
- package/src/lazy.js +0 -18
- package/src/loader.js +0 -198
- package/src/markets-ui.js +0 -90
- package/src/markets.js +0 -133
- package/src/mounting.js +0 -154
- package/src/parts/account-pages.js +0 -3
- package/src/parts/account.js +0 -3
- package/src/parts/analytics.js +0 -3
- package/src/parts/diagnose.js +0 -3
- package/src/parts/extras.js +0 -3
- package/src/parts/markets.js +0 -3
- package/src/parts/search.js +0 -3
- package/src/presets/barba.js +0 -100
- package/src/product.js +0 -570
- package/src/search.js +0 -153
- package/src/selling-plans.js +0 -94
- package/src/server.js +0 -252
- package/src/status.js +0 -66
- package/types/astro.d.ts +0 -17
- package/types/barba.d.ts +0 -16
- package/types/index.d.ts +0 -426
- package/types/server.d.ts +0 -110
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document-wide listeners whose work is in the optional extras part, loaded when one of them first has work:
|
|
3
|
+
* group add buttons, the cart or market changing in another tab, and shareable variant links. A part that can't load
|
|
4
|
+
* (a self-hosted site without `tether.extras.min.js`) is reported, so the missing file shows.
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, reportError } from '../config.ts';
|
|
7
|
+
import { attr, sel, busy, isOwnEvent } from '../dom.ts';
|
|
8
|
+
import { loadPart } from '../lazy.ts';
|
|
9
|
+
import type { ShopifyCart } from '../cart/store.ts';
|
|
10
|
+
|
|
11
|
+
/** `[tether-group]` with `[tether-group-add]`: adds every product scope inside the group in one request. */
|
|
12
|
+
export const addGroup = (button: Element): Promise<ShopifyCart | null> =>
|
|
13
|
+
loadPart('extras').then((part) => part.addGroup(button));
|
|
14
|
+
|
|
15
|
+
const reportExtrasError = (error: unknown) => {
|
|
16
|
+
reportError(error, 'extras');
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function onClick(event: MouseEvent): void {
|
|
20
|
+
const { target } = event;
|
|
21
|
+
const button = target instanceof window.Element ? target.closest(sel('group-add')) : null;
|
|
22
|
+
if (!button) return;
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
busy(button, () => addGroup(button)).catch(reportExtrasError);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function onStorage(event: StorageEvent): void {
|
|
28
|
+
if (!cfg.domain || !event.key?.startsWith(`${cfg.prefix}:`)) return;
|
|
29
|
+
loadPart('extras')
|
|
30
|
+
.then((part) => {
|
|
31
|
+
part.onStorage(event);
|
|
32
|
+
})
|
|
33
|
+
.catch(reportExtrasError);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param event `tether:variant-changed`, which a product scope emits; one another script dispatches is ignored. */
|
|
37
|
+
function onVariantChanged(event: Event): void {
|
|
38
|
+
if (!isOwnEvent(event, 'variant-changed')) return;
|
|
39
|
+
const { target } = event;
|
|
40
|
+
if (!(target instanceof window.Element)) return;
|
|
41
|
+
if (!cfg.variantUrl && !target.hasAttribute(attr('variant-url'))) return;
|
|
42
|
+
loadPart('extras')
|
|
43
|
+
.then((part) => {
|
|
44
|
+
part.writeVariantUrl(target, event.detail);
|
|
45
|
+
})
|
|
46
|
+
.catch(reportExtrasError);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Binds the document-wide listeners, once per page load of the script. */
|
|
50
|
+
export function bindExtrasControls(): void {
|
|
51
|
+
document.addEventListener('click', onClick);
|
|
52
|
+
window.addEventListener('storage', onStorage);
|
|
53
|
+
document.addEventListener(`${cfg.prefix}:variant-changed`, onVariantChanged);
|
|
54
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the page follows the visitor across pages and markets: Astro's ClientRouter (`astro:page-load`,
|
|
3
|
+
* `astro:before-swap`) mounts each new page in its market and closes the cart while leaving; a country select
|
|
4
|
+
* switches the market; back online, what failed to load loads again.
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, reportError } from '../config.ts';
|
|
7
|
+
import { attr, sel } from '../dom.ts';
|
|
8
|
+
import { liveLists, retryFailed } from '../mounting.ts';
|
|
9
|
+
import { marketForArrival, setCountry } from '../page-market.ts';
|
|
10
|
+
|
|
11
|
+
/** What the entry module does for a page. */
|
|
12
|
+
interface PageHooks {
|
|
13
|
+
/** Settles once `init()` has applied the visitor's market. */
|
|
14
|
+
ready: () => Promise<unknown>;
|
|
15
|
+
mount: (root: Document) => unknown;
|
|
16
|
+
unmount: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The body the last full-document mount ran on: Astro's first `astro:page-load` comes after that mount. */
|
|
20
|
+
let mountedBody: HTMLElement | null = null;
|
|
21
|
+
|
|
22
|
+
/** Records a mount of the whole document, so the `astro:page-load` of the same page sends no second round of requests. */
|
|
23
|
+
export function rememberDocumentMount(): void {
|
|
24
|
+
mountedBody = document.body;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Mounts the page Astro swapped in. It rejects when `init()` failed or the mount threw; the listener reports that. */
|
|
28
|
+
async function onPageLoad(hooks: PageHooks): Promise<void> {
|
|
29
|
+
await hooks.ready();
|
|
30
|
+
const target = marketForArrival();
|
|
31
|
+
if (!target) {
|
|
32
|
+
if (mountedBody !== document.body) hooks.mount(document);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
await setCountry(target.country, { language: target.language ?? null, locale: target.locale, remember: false });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
reportError(error, 'country');
|
|
39
|
+
}
|
|
40
|
+
hooks.mount(document);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function bindAstroRouter(hooks: PageHooks): void {
|
|
44
|
+
document.addEventListener('astro:page-load', () => {
|
|
45
|
+
onPageLoad(hooks).catch((error: unknown) => {
|
|
46
|
+
reportError(error, 'page mount');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
document.addEventListener('astro:before-swap', () => {
|
|
50
|
+
mountedBody = null;
|
|
51
|
+
hooks.unmount();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function onChange(event: Event): void {
|
|
56
|
+
const { target } = event;
|
|
57
|
+
if (!(target instanceof window.HTMLSelectElement) || !target.matches(sel('country-select'))) return;
|
|
58
|
+
if (!target.value) return;
|
|
59
|
+
setCountry(target.value).catch((error: unknown) => {
|
|
60
|
+
reportError(error, 'country');
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Loads the failed products and lists again. */
|
|
65
|
+
function onOnline() {
|
|
66
|
+
retryFailed();
|
|
67
|
+
liveLists.forEach((list) => {
|
|
68
|
+
if (list.root.getAttribute(attr('state')) === 'error') void list.load();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Binds the document-wide listeners, once per page load of the script. */
|
|
73
|
+
export function bindNavigation(hooks: PageHooks): void {
|
|
74
|
+
if (cfg.astro) bindAstroRouter(hooks);
|
|
75
|
+
document.addEventListener('change', onChange);
|
|
76
|
+
window.addEventListener('online', onOnline);
|
|
77
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The markup half of `Tether.diagnose()`: problems that show without the network. It knows every value and attribute
|
|
3
|
+
* name Tether reads, so a typo gets a "did you mean".
|
|
4
|
+
*/
|
|
5
|
+
import { cfg } from './config.ts';
|
|
6
|
+
import { attr, sel, get, qsa, hasTemplate } from './dom.ts';
|
|
7
|
+
import { resolveProductKey, boundary } from './status.ts';
|
|
8
|
+
|
|
9
|
+
type ProblemLevel = 'error' | 'warn';
|
|
10
|
+
|
|
11
|
+
export interface Problem {
|
|
12
|
+
level: ProblemLevel;
|
|
13
|
+
message: string;
|
|
14
|
+
/** The element, described for people: `<div#id.class>`. */
|
|
15
|
+
element?: string;
|
|
16
|
+
/** The element itself, for the console. */
|
|
17
|
+
el?: Element;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type AddProblem = (level: ProblemLevel, message: string, el?: Element) => void;
|
|
21
|
+
|
|
22
|
+
/** Every `tether-show` value. */
|
|
23
|
+
const SHOW = new Set([
|
|
24
|
+
'in-stock',
|
|
25
|
+
'out-of-stock',
|
|
26
|
+
'backorder',
|
|
27
|
+
'low-stock',
|
|
28
|
+
'plenty-in-stock',
|
|
29
|
+
'on-sale',
|
|
30
|
+
'not-on-sale',
|
|
31
|
+
'has-variants',
|
|
32
|
+
'single-variant',
|
|
33
|
+
'subscription-available',
|
|
34
|
+
'subscription-unavailable',
|
|
35
|
+
'subscription-only',
|
|
36
|
+
'one-time-available',
|
|
37
|
+
'subscription-selected',
|
|
38
|
+
'one-time-selected',
|
|
39
|
+
'has-savings',
|
|
40
|
+
'loading',
|
|
41
|
+
'ready',
|
|
42
|
+
'error',
|
|
43
|
+
'not-found',
|
|
44
|
+
'cart-empty',
|
|
45
|
+
'cart-filled',
|
|
46
|
+
'cart-has-subscription',
|
|
47
|
+
'cart-no-subscription',
|
|
48
|
+
'cart-has-discount',
|
|
49
|
+
'cart-error',
|
|
50
|
+
'cart-warning',
|
|
51
|
+
'list-empty',
|
|
52
|
+
'list-filled',
|
|
53
|
+
'search-empty',
|
|
54
|
+
'search-results',
|
|
55
|
+
'logged-in',
|
|
56
|
+
'logged-out',
|
|
57
|
+
'orders-empty',
|
|
58
|
+
'orders-loading',
|
|
59
|
+
'addresses-loading',
|
|
60
|
+
'addresses-empty',
|
|
61
|
+
'addresses-error',
|
|
62
|
+
'subscriptions-loading',
|
|
63
|
+
'subscriptions-empty',
|
|
64
|
+
'subscriptions-error',
|
|
65
|
+
]);
|
|
66
|
+
/** Every `tether-bind` value, apart from `image:<n>` and `metafield:<namespace.key>`. */
|
|
67
|
+
const BIND = new Set([
|
|
68
|
+
'title',
|
|
69
|
+
'description',
|
|
70
|
+
'description-text',
|
|
71
|
+
'vendor',
|
|
72
|
+
'type',
|
|
73
|
+
'handle',
|
|
74
|
+
'published-at',
|
|
75
|
+
'price',
|
|
76
|
+
'total-price',
|
|
77
|
+
'compare-at-price',
|
|
78
|
+
'unit-price',
|
|
79
|
+
'min-price',
|
|
80
|
+
'max-price',
|
|
81
|
+
'discount-percentage',
|
|
82
|
+
'discount-amount',
|
|
83
|
+
'variant-title',
|
|
84
|
+
'variant-id',
|
|
85
|
+
'sku',
|
|
86
|
+
'barcode',
|
|
87
|
+
'stock',
|
|
88
|
+
'quantity',
|
|
89
|
+
'featured-image',
|
|
90
|
+
'variant-image',
|
|
91
|
+
'images',
|
|
92
|
+
'tags',
|
|
93
|
+
'link',
|
|
94
|
+
'url',
|
|
95
|
+
'one-time-price',
|
|
96
|
+
'subscription-price',
|
|
97
|
+
'subscription-savings',
|
|
98
|
+
'subscription-savings-amount',
|
|
99
|
+
'checkout-charge',
|
|
100
|
+
'plan-name',
|
|
101
|
+
'plan-description',
|
|
102
|
+
'plan-group',
|
|
103
|
+
'option-value',
|
|
104
|
+
'swatch',
|
|
105
|
+
'image',
|
|
106
|
+
]);
|
|
107
|
+
/** Every attribute name Tether reads, without the prefix. */
|
|
108
|
+
const ATTRIBUTES = new Set(
|
|
109
|
+
`account account-bind account-link account-login account-logout add-label add-to-cart adding-text
|
|
110
|
+
always-enabled bind cart-checkout-charge cart-clear cart-close cart-count cart-discounts cart-drawer cart-label cart-lines
|
|
111
|
+
cart-note cart-open cart-subtotal cart-total cart-warning checkout code collection collection-bind countries country
|
|
112
|
+
country-bind country-label country-select default-plan default-purchase discount discount-code discount-form discount-input
|
|
113
|
+
discount-remove display error group group-add hide-single language limit line line-attribute line-attributes line-id
|
|
114
|
+
line-minus line-plan line-plus line-remove line-show list load-more market-pending one-time-label option option-list
|
|
115
|
+
option-selected option-value order order-bind order-line order-lines order-line-show order-show order-state order-fulfillments
|
|
116
|
+
fulfillment-bind order-return return-quantity return-reason return-note orders profile-form address-form addresses address-bind
|
|
117
|
+
address-show address-edit address-new address-delete address-default subscriptions subscription-bind subscription-lines
|
|
118
|
+
subscription-line subscription-show subscription-action subscription-status subscription-state confirm form-state form-error page-country page-language page-locale page-title plan plan-bind plan-list
|
|
119
|
+
plan-value product products purchase purchase-mode qty qty-minus qty-plus quantity recommendations reverse search
|
|
120
|
+
search-bind search-collections search-input search-limit search-min search-products search-queries search-results
|
|
121
|
+
search-state show soldout-text sort state subscribe-text variant variant-select variant-url max-width max-height crop
|
|
122
|
+
sig label hydrated busy`
|
|
123
|
+
.split(/\s+/)
|
|
124
|
+
.filter(Boolean),
|
|
125
|
+
);
|
|
126
|
+
/** Attributes that only work inside a `tether-product`. */
|
|
127
|
+
const PRODUCT_ONLY = [
|
|
128
|
+
'bind',
|
|
129
|
+
'option-list',
|
|
130
|
+
'option',
|
|
131
|
+
'variant-select',
|
|
132
|
+
'add-to-cart',
|
|
133
|
+
'purchase',
|
|
134
|
+
'plan-list',
|
|
135
|
+
'plan',
|
|
136
|
+
'qty',
|
|
137
|
+
'line-attribute',
|
|
138
|
+
];
|
|
139
|
+
const REPEATERS = ['option-list', 'plan-list', 'cart-lines', 'list'];
|
|
140
|
+
const METAFIELD_BIND = 'metafield:';
|
|
141
|
+
const IMAGE_BIND = /^image:\d+$/;
|
|
142
|
+
/** The class names of an element that its description shows. */
|
|
143
|
+
const DESCRIBED_CLASSES = 2;
|
|
144
|
+
|
|
145
|
+
/** The Levenshtein distance: the fewest single-character edits from one string to the other. */
|
|
146
|
+
function editDistance(from: string, to: string): number {
|
|
147
|
+
let previous: number[] = Array.from({ length: to.length + 1 }, (_, index) => index);
|
|
148
|
+
for (let i = 1; i <= from.length; i++) {
|
|
149
|
+
const row = [i];
|
|
150
|
+
for (let j = 1; j <= to.length; j++) {
|
|
151
|
+
const substitution = (previous[j - 1] ?? 0) + (from[i - 1] === to[j - 1] ? 0 : 1);
|
|
152
|
+
row.push(Math.min((previous[j] ?? 0) + 1, (row[j - 1] ?? 0) + 1, substitution));
|
|
153
|
+
}
|
|
154
|
+
previous = row;
|
|
155
|
+
}
|
|
156
|
+
return previous[to.length] ?? 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param value What the markup says.
|
|
161
|
+
* @param options What Tether reads.
|
|
162
|
+
* @param prefix Put before the suggestion, e.g. `tether-`.
|
|
163
|
+
* @returns ` Did you mean "…"?` for an option close enough to `value`, else `''`.
|
|
164
|
+
*/
|
|
165
|
+
export function didYouMean(value: string, options: Iterable<string>, prefix = ''): string {
|
|
166
|
+
let best: string | null = null;
|
|
167
|
+
let bestDistance = Infinity;
|
|
168
|
+
for (const option of options) {
|
|
169
|
+
const distance = editDistance(value, option);
|
|
170
|
+
if (distance < bestDistance) {
|
|
171
|
+
bestDistance = distance;
|
|
172
|
+
best = option;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const closeEnough = Math.max(2, Math.floor(value.length / 4));
|
|
176
|
+
return best !== null && bestDistance <= closeEnough ? ` Did you mean "${prefix}${best}"?` : '';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `el` described for people: `<div#id.first.second>`. */
|
|
180
|
+
function describe(el: Element): string {
|
|
181
|
+
const id = el.id ? `#${el.id}` : '';
|
|
182
|
+
const classes = el.className && typeof el.className === 'string' ? el.className.trim().split(/\s+/) : [];
|
|
183
|
+
const classText = classes.length ? `.${classes.slice(0, DESCRIBED_CLASSES).join('.')}` : '';
|
|
184
|
+
return `<${el.tagName.toLowerCase()}${id}${classText}>`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const listSelector = () => `${sel('collection')},${sel('products')},${sel('recommendations')}`;
|
|
188
|
+
|
|
189
|
+
function checkProductKeys(root: ParentNode, add: AddProblem): void {
|
|
190
|
+
qsa(root, sel('product')).forEach((el) => {
|
|
191
|
+
// A list card gets its handle when the list renders.
|
|
192
|
+
if (el.closest(listSelector()) && el.closest(sel('list'))) return;
|
|
193
|
+
const { key, fromUrl, raw } = resolveProductKey(el);
|
|
194
|
+
if (!raw) {
|
|
195
|
+
add(
|
|
196
|
+
'error',
|
|
197
|
+
`${attr('product')} has no value. Bind it to the Shopify handle (CMS field), or use "url:handle".`,
|
|
198
|
+
el,
|
|
199
|
+
);
|
|
200
|
+
} else if (fromUrl && !key) {
|
|
201
|
+
add('warn', `${attr('product')}="${raw}" but this URL has no value for it (e.g. ?handle=my-product).`, el);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function checkPlacement(root: ParentNode, add: AddProblem): void {
|
|
207
|
+
for (const name of PRODUCT_ONLY) {
|
|
208
|
+
qsa(root, sel(name)).forEach((el) => {
|
|
209
|
+
if (el.closest(sel('product'))) return;
|
|
210
|
+
if (name === 'line-attribute' && el.closest(sel('group'))) return;
|
|
211
|
+
add('warn', `${attr(name)} outside any ${attr('product')}: it does nothing here.`, el);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
qsa(root, sel('group-add')).forEach((el) => {
|
|
215
|
+
if (!el.closest(sel('group'))) add('warn', `${attr('group-add')} outside a ${attr('group')}.`, el);
|
|
216
|
+
});
|
|
217
|
+
for (const name of REPEATERS) {
|
|
218
|
+
qsa(root, sel(name)).forEach((el) => {
|
|
219
|
+
if (!el.firstElementChild && !hasTemplate(el)) {
|
|
220
|
+
add('warn', `${attr(name)} without a template child: put one example element inside it.`, el);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
qsa(root, listSelector()).forEach((el) => {
|
|
225
|
+
if (!qsa(el, sel('list')).length) {
|
|
226
|
+
add('error', `List without a ${attr('list')} child: products have nowhere to render.`, el);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function checkValues(root: ParentNode, add: AddProblem): void {
|
|
232
|
+
qsa(root, sel('show')).forEach((el) => {
|
|
233
|
+
const value = get(el, 'show') ?? '';
|
|
234
|
+
if (!SHOW.has(value)) add('warn', `Unknown ${attr('show')}="${value}".${didYouMean(value, SHOW)}`, el);
|
|
235
|
+
});
|
|
236
|
+
qsa(root, sel('bind')).forEach((el) => {
|
|
237
|
+
const value = get(el, 'bind') || '';
|
|
238
|
+
if (!value || BIND.has(value) || IMAGE_BIND.test(value) || value.startsWith(METAFIELD_BIND)) return;
|
|
239
|
+
add('warn', `Unknown ${attr('bind')}="${value}": it stays empty.${didYouMean(value, BIND)}`, el);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function checkMetafieldBinds(root: ParentNode, add: AddProblem): void {
|
|
244
|
+
qsa(root, sel('bind')).forEach((el) => {
|
|
245
|
+
const value = get(el, 'bind') || '';
|
|
246
|
+
if (!value.startsWith(METAFIELD_BIND)) return;
|
|
247
|
+
const ref = value.slice(METAFIELD_BIND.length);
|
|
248
|
+
const listed = cfg.metafields.some(
|
|
249
|
+
(metafield) => (typeof metafield === 'string' ? metafield : `${metafield.namespace}.${metafield.key}`) === ref,
|
|
250
|
+
);
|
|
251
|
+
if (!listed) {
|
|
252
|
+
add('error', `${attr('bind')}="${value}" but "${ref}" is not in config.metafields, so it stays empty.`, el);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Typos in attribute names (tether-bnd, tether-add-to-card) do nothing, silently. Each unknown name is reported once.
|
|
259
|
+
*/
|
|
260
|
+
function checkAttributeNames(root: ParentNode, add: AddProblem): void {
|
|
261
|
+
const prefix = `${cfg.prefix}-`;
|
|
262
|
+
const reported = new Set<string>();
|
|
263
|
+
qsa(root, '*').forEach((el) => {
|
|
264
|
+
for (const { name: fullName } of el.attributes) {
|
|
265
|
+
if (!fullName.startsWith(prefix)) continue;
|
|
266
|
+
const name = fullName.slice(prefix.length);
|
|
267
|
+
if (ATTRIBUTES.has(name) || reported.has(name)) continue;
|
|
268
|
+
reported.add(name);
|
|
269
|
+
add('warn', `Unknown attribute ${fullName}: Tether ignores it.${didYouMean(name, ATTRIBUTES, prefix)}`, el);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function checkCartAndScopes(root: ParentNode, add: AddProblem): void {
|
|
275
|
+
const drawers = qsa(root, sel('cart-drawer')).length;
|
|
276
|
+
if (qsa(root, sel('cart-open')).length && !drawers) {
|
|
277
|
+
add('warn', `${attr('cart-open')} exists but there is no ${attr('cart-drawer')} on this page.`);
|
|
278
|
+
}
|
|
279
|
+
if (drawers > 1) add('warn', `More than one ${attr('cart-drawer')}.`);
|
|
280
|
+
qsa(root, `[${attr('state')}="error"]`).forEach((el) => {
|
|
281
|
+
if (el.matches(boundary())) add('error', `Scope failed to load (${get(el, 'error') || 'error'}).`, el);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The markup problems in `root`, in page order per check. */
|
|
286
|
+
export function checkMarkup(root: ParentNode = document): Problem[] {
|
|
287
|
+
const problems: Problem[] = [];
|
|
288
|
+
const add: AddProblem = (level, message, el) => {
|
|
289
|
+
problems.push(el ? { level, message, element: describe(el), el } : { level, message });
|
|
290
|
+
};
|
|
291
|
+
checkProductKeys(root, add);
|
|
292
|
+
checkPlacement(root, add);
|
|
293
|
+
checkValues(root, add);
|
|
294
|
+
checkAttributeNames(root, add);
|
|
295
|
+
checkMetafieldBinds(root, add);
|
|
296
|
+
checkCartAndScopes(root, add);
|
|
297
|
+
return problems;
|
|
298
|
+
}
|