@occluze/tether 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -109
- package/dist/esm/astro.js +1 -1
- package/dist/esm/chunks/{account-UIPWJQ3Y.js → account-W4FBRKXA.js} +1 -1
- package/dist/esm/chunks/{account-pages-6ROA6Y4V.js → account-pages-IM3OQTOP.js} +1 -1
- package/dist/esm/chunks/analytics-QDVOLZY3.js +1 -0
- package/dist/esm/chunks/{chunk-T44MUL4Y.js → chunk-AUVAN3KZ.js} +1 -1
- package/dist/esm/chunks/chunk-BMDYNMRC.js +1 -0
- package/dist/esm/chunks/chunk-CUL66KRA.js +1 -0
- package/dist/esm/chunks/{chunk-6OZJCAGZ.js → chunk-CVNRODH3.js} +1 -1
- package/dist/esm/chunks/{chunk-5Z5O3767.js → chunk-CXHPBT7N.js} +1 -1
- package/dist/esm/chunks/chunk-GF6JTI65.js +1 -0
- package/dist/esm/chunks/chunk-MRSDJPBC.js +1 -0
- package/dist/esm/chunks/chunk-NYDZPTLP.js +1 -0
- package/dist/esm/chunks/chunk-QDHLXXAZ.js +1 -0
- package/dist/esm/chunks/{chunk-4GCXM764.js → chunk-QUECUXHD.js} +1 -1
- package/dist/esm/chunks/chunk-RJY76ESL.js +1 -0
- package/dist/esm/chunks/chunk-RPI5C3EJ.js +1 -0
- package/dist/esm/chunks/{chunk-BFTZR4OB.js → chunk-YC4JXWRG.js} +2 -2
- package/dist/esm/chunks/{diagnose-3FEKRIEK.js → diagnose-3XHPZZNP.js} +1 -1
- package/dist/esm/chunks/extras-C724KKG6.js +1 -0
- package/dist/esm/chunks/{markets-ui-FD6NI2OG.js → markets-ui-WBWWQAGU.js} +1 -1
- package/dist/esm/chunks/{search-UJ73YBQ5.js → search-5NFIZBPJ.js} +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -2
- package/dist/tether.account-pages.js +13 -3
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +13 -3
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +18 -3
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +20 -5
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +52 -28
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +353 -288
- package/dist/tether.markets.js +31 -5
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +2 -2
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +18 -3
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/cart-id.d.ts +2 -0
- package/dist/types/src/cart/mutations.d.ts +6 -0
- package/dist/types/src/cart/store.d.ts +5 -2
- package/dist/types/src/cart.d.ts +3 -2
- package/dist/types/src/client.d.ts +61 -22
- package/dist/types/src/config.d.ts +6 -1
- package/dist/types/src/index.d.ts +2 -2
- package/dist/types/src/markets.d.ts +9 -2
- package/dist/types/src/product-shape.d.ts +3 -1
- package/dist/types/src/uri.d.ts +6 -0
- package/package.json +54 -6
- package/dist/esm/chunks/analytics-2V632IRV.js +0 -1
- package/dist/esm/chunks/chunk-56FCLJJD.js +0 -1
- package/dist/esm/chunks/chunk-A5SYCVQM.js +0 -1
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +0 -1
- package/dist/esm/chunks/chunk-C5JYQCWH.js +0 -1
- package/dist/esm/chunks/chunk-E5YO6274.js +0 -1
- package/dist/esm/chunks/chunk-MPDWM2NN.js +0 -1
- package/dist/esm/chunks/chunk-YLUULATY.js +0 -1
- package/dist/esm/chunks/extras-6POFBNOP.js +0 -1
- package/src/account/addresses-view.ts +0 -58
- package/src/account/answers.ts +0 -79
- package/src/account/markup.ts +0 -36
- package/src/account/operations.ts +0 -145
- package/src/account/order-lines.ts +0 -75
- package/src/account/order-list.ts +0 -107
- package/src/account/order-page.ts +0 -137
- package/src/account/page-answers.ts +0 -317
- package/src/account/page-events.ts +0 -253
- package/src/account/server-client.ts +0 -183
- package/src/account/subscriptions-view.ts +0 -115
- package/src/account-api.ts +0 -98
- package/src/account-marks.ts +0 -35
- package/src/account-pages.ts +0 -81
- package/src/account.ts +0 -284
- package/src/amounts.ts +0 -64
- package/src/analytics-shopify.ts +0 -246
- package/src/analytics.ts +0 -315
- package/src/api-version.ts +0 -79
- package/src/astro.ts +0 -272
- package/src/cart/cart-shape.ts +0 -25
- package/src/cart/checkout.ts +0 -101
- package/src/cart/drawer.ts +0 -125
- package/src/cart/mutations.ts +0 -237
- package/src/cart/render.ts +0 -266
- package/src/cart/store.ts +0 -279
- package/src/cart.ts +0 -345
- package/src/client.ts +0 -391
- package/src/collection.ts +0 -175
- package/src/config-sections.ts +0 -284
- package/src/config.ts +0 -480
- package/src/controls/cart-controls.ts +0 -123
- package/src/controls/discount.ts +0 -75
- package/src/controls/extras.ts +0 -54
- package/src/controls/navigation.ts +0 -77
- package/src/diagnose-markup.ts +0 -298
- package/src/diagnose.ts +0 -294
- package/src/dom.ts +0 -268
- package/src/extras.ts +0 -136
- package/src/fragments.ts +0 -261
- package/src/globals.d.ts +0 -15
- package/src/images.ts +0 -91
- package/src/index.ts +0 -415
- package/src/initial.ts +0 -101
- package/src/lazy-iife.ts +0 -197
- package/src/lazy.ts +0 -42
- package/src/loader.ts +0 -363
- package/src/market-codes.ts +0 -6
- package/src/markets-ui.ts +0 -227
- package/src/markets.ts +0 -246
- package/src/markup.ts +0 -127
- package/src/mounting.ts +0 -264
- package/src/page-market.ts +0 -110
- package/src/part-integrity.ts +0 -7
- package/src/parts/account-pages.ts +0 -7
- package/src/parts/account.ts +0 -7
- package/src/parts/analytics.ts +0 -7
- package/src/parts/diagnose.ts +0 -7
- package/src/parts/extras.ts +0 -7
- package/src/parts/markets.ts +0 -7
- package/src/parts/search.ts +0 -7
- package/src/presets/barba.ts +0 -194
- package/src/product-events.ts +0 -115
- package/src/product-render.ts +0 -218
- package/src/product-shape.ts +0 -49
- package/src/product-state.ts +0 -318
- package/src/product.ts +0 -308
- package/src/repeaters.ts +0 -161
- package/src/search.ts +0 -305
- package/src/selling-plans.ts +0 -145
- package/src/server.ts +0 -211
- package/src/shopify-values.ts +0 -91
- package/src/status.ts +0 -106
- package/src/storage.ts +0 -34
- package/src/store-html.ts +0 -359
- package/src/storefront/catalog.ts +0 -196
- package/src/storefront/json-ld.ts +0 -144
- package/src/variant-render.ts +0 -139
package/src/server.ts
DELETED
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `@occluze/tether/server`: Shopify product data at build time or on the server (Astro, Eleventy, Node, Cloudflare
|
|
3
|
-
* Workers). No browser APIs. It sends the browser script's own queries, so products handed over with `hydrate()` are
|
|
4
|
-
* exactly what the script expects and need no request of their own.
|
|
5
|
-
*
|
|
6
|
-
* import { createStorefront } from '@occluze/tether/server';
|
|
7
|
-
* const shop = createStorefront({ domain, token, country: 'NL' });
|
|
8
|
-
* const product = await shop.getProduct('coffee-box');
|
|
9
|
-
* const all = await shop.allProducts();
|
|
10
|
-
*/
|
|
11
|
-
import { DEFAULTS, resolveConfig } from './config.ts';
|
|
12
|
-
import { request, hasData, withContext, TetherError } from './client.ts';
|
|
13
|
-
import * as plans from './selling-plans.ts';
|
|
14
|
-
import { imageUrl, formatPercent } from './dom.ts';
|
|
15
|
-
import { initialState } from './initial.ts';
|
|
16
|
-
import { STORE_ENV_NAMES, formatMoney, productKey, productUrl as productUrlFor } from './shopify-values.ts';
|
|
17
|
-
import { createCatalog } from './storefront/catalog.ts';
|
|
18
|
-
import { jsonLdData, scriptJson } from './storefront/json-ld.ts';
|
|
19
|
-
import type { TetherConfig, TetherOptions } from './config.ts';
|
|
20
|
-
import type { Product } from './fragments.ts';
|
|
21
|
-
import type { Money } from './shopify-values.ts';
|
|
22
|
-
import type { InitialOptions, InitialState } from './initial.ts';
|
|
23
|
-
import type { JsonLdOptions } from './storefront/json-ld.ts';
|
|
24
|
-
|
|
25
|
-
export { imageUrl, initialState, formatPercent, formatMoney, productKey };
|
|
26
|
-
|
|
27
|
-
export { TetherError, plans, withContext };
|
|
28
|
-
|
|
29
|
-
// The types a site uses by name, under the names the docs give them.
|
|
30
|
-
export type { StorefrontOptions, HydrateOptions };
|
|
31
|
-
export type { Money } from './shopify-values.ts';
|
|
32
|
-
export type { Product as ShopifyProduct, ProductVariant as ShopifyVariant } from './fragments.ts';
|
|
33
|
-
export type { ShopifyCart, ShopifyCartLine } from './cart/store.ts';
|
|
34
|
-
export type { Collection as ShopifyCollection, ProductQuery } from './loader.ts';
|
|
35
|
-
export type { InitialOptions, InitialState } from './initial.ts';
|
|
36
|
-
export type { ImageSize } from './images.ts';
|
|
37
|
-
export type { JsonLdOptions } from './storefront/json-ld.ts';
|
|
38
|
-
/** What `createStorefront()` returns. */
|
|
39
|
-
export type Storefront = ReturnType<typeof createStorefront>;
|
|
40
|
-
|
|
41
|
-
/** The options only a storefront has. */
|
|
42
|
-
interface ServerOptions {
|
|
43
|
-
/** Replaces the global `fetch` (tests, caching). */
|
|
44
|
-
fetch?: typeof fetch;
|
|
45
|
-
/** Products per request for `allProducts()` (default 25). */
|
|
46
|
-
pageSize?: number;
|
|
47
|
-
/** The site's address, for product URLs in `jsonLd()` without `url`. */
|
|
48
|
-
siteUrl?: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* The config options, or an `import.meta.env` spread into them: its `PUBLIC_SHOPIFY_*` names give the domain and
|
|
53
|
-
* token, its other keys are ignored.
|
|
54
|
-
*/
|
|
55
|
-
type StorefrontOptions = TetherOptions & ServerOptions & StoreEnv;
|
|
56
|
-
|
|
57
|
-
/** The environment names that give the domain and the token. */
|
|
58
|
-
type StoreEnv = Partial<Record<(typeof STORE_ENV_NAMES)['domain' | 'token'][number], string>>;
|
|
59
|
-
|
|
60
|
-
type ServerConfig = TetherConfig & ServerOptions;
|
|
61
|
-
|
|
62
|
-
interface HydrateOptions {
|
|
63
|
-
card?: boolean;
|
|
64
|
-
images?: number;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const DEFAULT_PAGE_SIZE = 25;
|
|
68
|
-
/** The images a card keeps: the main one and a hover image (`tether-bind="image:2"`). */
|
|
69
|
-
const DEFAULT_CARD_IMAGES = 2;
|
|
70
|
-
/** The version of the `data-tether-hydrate` payload, for the browser script reading it. */
|
|
71
|
-
const HYDRATE_VERSION = 1;
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* @returns `productPath` `/products` → `/products/<handle>`; templates with `{handle}` and `{id}` work too. `null` when
|
|
75
|
-
* the path needs a handle or an id that is empty.
|
|
76
|
-
*/
|
|
77
|
-
export function productUrl(
|
|
78
|
-
handle: string,
|
|
79
|
-
{ productPath = DEFAULTS.productPath, id }: { productPath?: string; id?: string } = {},
|
|
80
|
-
): string | null {
|
|
81
|
-
return productUrlFor(productPath, handle, id);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** @returns The first of the `names` set in `options`, `''` without one. */
|
|
85
|
-
function fromEnv(options: StoreEnv, names: readonly (keyof StoreEnv)[]): string {
|
|
86
|
-
for (const name of names) {
|
|
87
|
-
const value = options[name];
|
|
88
|
-
if (typeof value === 'string' && value) return value;
|
|
89
|
-
}
|
|
90
|
-
return '';
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* @returns The config options of `options`, resolved and validated as the browser's are, with the domain and token
|
|
95
|
-
* from the environment names when they aren't set.
|
|
96
|
-
*/
|
|
97
|
-
function storefrontConfig(options: StorefrontOptions): ServerConfig {
|
|
98
|
-
const known: Record<string, unknown> = {};
|
|
99
|
-
for (const [key, value] of Object.entries(options)) {
|
|
100
|
-
if (key in DEFAULTS) known[key] = value;
|
|
101
|
-
}
|
|
102
|
-
const domain = typeof options.domain === 'string' && options.domain ? options.domain : '';
|
|
103
|
-
const token = typeof options.token === 'string' && options.token ? options.token : '';
|
|
104
|
-
const conf: ServerConfig = resolveConfig({
|
|
105
|
-
// Only config keys, each parsed by `resolveConfig`.
|
|
106
|
-
...known,
|
|
107
|
-
domain: domain || fromEnv(options, STORE_ENV_NAMES.domain),
|
|
108
|
-
token: token || fromEnv(options, STORE_ENV_NAMES.token),
|
|
109
|
-
});
|
|
110
|
-
if (options.fetch) conf.fetch = options.fetch;
|
|
111
|
-
if (options.pageSize !== undefined) conf.pageSize = options.pageSize;
|
|
112
|
-
if (options.siteUrl !== undefined) conf.siteUrl = options.siteUrl;
|
|
113
|
-
return conf;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** @returns `pageSize` when it is a positive integer, else the default. */
|
|
117
|
-
const validPageSize = (pageSize: number | undefined): number =>
|
|
118
|
-
typeof pageSize === 'number' && Number.isInteger(pageSize) && pageSize > 0 ? pageSize : DEFAULT_PAGE_SIZE;
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* A product for a list (home, collections, recommendations): without descriptions and variant images, and only its
|
|
122
|
-
* first `images` images. Prices, stock and plans per variant stay.
|
|
123
|
-
*/
|
|
124
|
-
function cardOf(product: Product, images: number): Product {
|
|
125
|
-
return {
|
|
126
|
-
...product,
|
|
127
|
-
description: '',
|
|
128
|
-
descriptionHtml: '',
|
|
129
|
-
images: { nodes: product.images.nodes.slice(0, Math.max(1, images)) },
|
|
130
|
-
variants: { nodes: product.variants.nodes.map((variant) => ({ ...variant, image: null })) },
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* A storefront: the store's products and collections for the server, and the tags that hand them to the page.
|
|
136
|
-
*
|
|
137
|
-
* @throws {TetherError} `NOT_CONFIGURED` without a domain and a token.
|
|
138
|
-
*/
|
|
139
|
-
export function createStorefront(options: StorefrontOptions = {}) {
|
|
140
|
-
const conf = storefrontConfig(options);
|
|
141
|
-
if (!conf.domain || !conf.token) {
|
|
142
|
-
throw new TetherError(
|
|
143
|
-
'createStorefront needs domain and token: pass them, or import.meta.env with PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN',
|
|
144
|
-
{ code: 'NOT_CONFIGURED' },
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
const catalog = createCatalog(conf, validPageSize(conf.pageSize));
|
|
148
|
-
const site = { productPath: conf.productPath, siteUrl: conf.siteUrl };
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* A `<script type="application/json" data-tether-hydrate>` tag that hands products to the browser script: it renders
|
|
152
|
-
* them at once, without a request of its own, then refreshes prices and stock once in the background. Astro:
|
|
153
|
-
* `<Fragment set:html={shop.hydrate(products)} />`.
|
|
154
|
-
*
|
|
155
|
-
* @param options `card: true` for lists: about 40% smaller (see `cardOf`). The full product comes with the
|
|
156
|
-
* background refresh.
|
|
157
|
-
*/
|
|
158
|
-
function hydrate(
|
|
159
|
-
products: Product | null | undefined | (Product | null | undefined)[],
|
|
160
|
-
{ card = false, images = DEFAULT_CARD_IMAGES }: HydrateOptions = {},
|
|
161
|
-
): string {
|
|
162
|
-
const present = (Array.isArray(products) ? products : [products]).filter((product) => product != null);
|
|
163
|
-
const payload = {
|
|
164
|
-
v: HYDRATE_VERSION,
|
|
165
|
-
at: new Date().toISOString(),
|
|
166
|
-
country: conf.country || null,
|
|
167
|
-
language: conf.language || null,
|
|
168
|
-
products: card ? present.map((product) => cardOf(product, images)) : present,
|
|
169
|
-
};
|
|
170
|
-
return `<script type="application/json" data-tether-hydrate>${scriptJson(payload)}</script>`;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** @returns The `<script type="application/ld+json">` tag, `''` without a product. */
|
|
174
|
-
function jsonLd(product: Product | null | undefined, jsonLdOptions: JsonLdOptions = {}): string {
|
|
175
|
-
if (!product) return '';
|
|
176
|
-
return `<script type="application/ld+json">${scriptJson(jsonLdData(product, jsonLdOptions, site))}</script>`;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** `initialState()` with the storefront's `defaultPurchase` and `lowStock`. */
|
|
180
|
-
function storefrontInitialState(product: Product, initialOptions?: InitialOptions): InitialState;
|
|
181
|
-
function storefrontInitialState(
|
|
182
|
-
product: Product | null | undefined,
|
|
183
|
-
initialOptions?: InitialOptions,
|
|
184
|
-
): InitialState | null;
|
|
185
|
-
function storefrontInitialState(
|
|
186
|
-
product: Product | null | undefined,
|
|
187
|
-
initialOptions: InitialOptions = {},
|
|
188
|
-
): InitialState | null {
|
|
189
|
-
return initialState(product, { defaultPurchase: conf.defaultPurchase, lowStock: conf.lowStock, ...initialOptions });
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return {
|
|
193
|
-
config: conf,
|
|
194
|
-
query: <T>(query: string, variables?: Record<string, unknown>, requestOptions?: { context?: boolean }) =>
|
|
195
|
-
// The caller's own query text decides the shape of `data`, and the caller's `T` states it.
|
|
196
|
-
request(conf, query, variables ?? {}, hasData, requestOptions) as Promise<T>,
|
|
197
|
-
...catalog,
|
|
198
|
-
hydrate,
|
|
199
|
-
formatMoney: (money: Money | null | undefined, locale: string | undefined = conf.locale) =>
|
|
200
|
-
formatMoney(money, locale),
|
|
201
|
-
formatPercent: (percentage: number, locale: string | undefined = conf.locale) => formatPercent(percentage, locale),
|
|
202
|
-
imageUrl,
|
|
203
|
-
jsonLd,
|
|
204
|
-
jsonLdData: (product: Product | null | undefined, jsonLdOptions: JsonLdOptions = {}) =>
|
|
205
|
-
jsonLdData(product, jsonLdOptions, site),
|
|
206
|
-
initialState: storefrontInitialState,
|
|
207
|
-
productUrl: (handle: string, urlOptions: { productPath?: string; id?: string } = {}) =>
|
|
208
|
-
productUrl(handle, { productPath: conf.productPath, ...urlOptions }),
|
|
209
|
-
plans,
|
|
210
|
-
};
|
|
211
|
-
}
|
package/src/shopify-values.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Conversions of Shopify values that the browser script and the server helpers (`src/server.ts`, `src/astro.ts`)
|
|
3
|
-
* share, so a server-rendered page and the script that takes it over agree. Pure: no config, no DOM.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/** A Storefront API `MoneyV2`. */
|
|
7
|
-
export interface Money {
|
|
8
|
-
amount: string;
|
|
9
|
-
currencyCode: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** The `.env` names a store's domain and public Storefront token are read from, first match wins. */
|
|
13
|
-
export const STORE_ENV_NAMES = {
|
|
14
|
-
domain: ['PUBLIC_SHOPIFY_DOMAIN', 'PUBLIC_SHOPIFY_STORE_DOMAIN', 'SHOPIFY_DOMAIN'],
|
|
15
|
-
token: ['PUBLIC_SHOPIFY_STOREFRONT_TOKEN', 'PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN', 'SHOPIFY_STOREFRONT_TOKEN'],
|
|
16
|
-
} as const;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* True for an absolute https URL. Links and redirects that come from Shopify (checkout, status pages,
|
|
20
|
-
* tracking) are only used when this holds.
|
|
21
|
-
*/
|
|
22
|
-
export function isHttpsUrl(url: unknown): url is string {
|
|
23
|
-
if (typeof url !== 'string') return false;
|
|
24
|
-
try {
|
|
25
|
-
return new URL(url).protocol === 'https:';
|
|
26
|
-
} catch {
|
|
27
|
-
// Not an absolute URL, so not an https one.
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* True for a token Shopify issues as a secret (`shpat_` Admin, `shpss_` shared secret, `shpca_` client credentials,
|
|
34
|
-
* `shppa_` private app, `shpua_` user). The public Storefront token has no such prefix. A secret token must never
|
|
35
|
-
* reach a page.
|
|
36
|
-
*/
|
|
37
|
-
export function isSecretShopifyToken(token: string): boolean {
|
|
38
|
-
return /^shp[a-z]{2}_/.test(token.trim());
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Money as text in `locale`, e.g. `{ amount: '30.0', currencyCode: 'EUR' }` in `nl-NL` → `€ 30,00`.
|
|
43
|
-
*
|
|
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
|
-
*/
|
|
47
|
-
export function formatMoney(money: Money | null | undefined, locale: string | undefined): string {
|
|
48
|
-
if (!money) return '';
|
|
49
|
-
const amount = parseFloat(money.amount);
|
|
50
|
-
if (Number.isNaN(amount)) return '';
|
|
51
|
-
try {
|
|
52
|
-
return new Intl.NumberFormat(locale || undefined, { style: 'currency', currency: money.currencyCode }).format(
|
|
53
|
-
amount,
|
|
54
|
-
);
|
|
55
|
-
} catch {
|
|
56
|
-
// A currency code Intl doesn't know.
|
|
57
|
-
return `${money.currencyCode} ${amount.toFixed(2)}`;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* A product page's path. `productPath` is a folder (`/product` → `/product/<handle>`) or a template with `{handle}`
|
|
63
|
-
* and `{id}` (the numeric product id), e.g. `/product?handle={handle}`.
|
|
64
|
-
*
|
|
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.
|
|
67
|
-
*/
|
|
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;
|
|
75
|
-
return productPath
|
|
76
|
-
.replace(/\{handle\}/g, encodeURIComponent(handle ?? ''))
|
|
77
|
-
.replace(/\{id\}/g, encodeURIComponent(numericId));
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* The key Tether loads a product by: a handle, or a product gid.
|
|
82
|
-
*
|
|
83
|
-
* @param raw A handle, a numeric id, a gid or a product URL (`…/products/<handle>`).
|
|
84
|
-
*/
|
|
85
|
-
export function productKey(raw: string | number | null | undefined): string | null {
|
|
86
|
-
const value = String(raw ?? '').trim();
|
|
87
|
-
if (!value) return null;
|
|
88
|
-
if (value.startsWith('gid://')) return value;
|
|
89
|
-
if (/^\d+$/.test(value)) return `gid://shopify/Product/${value}`;
|
|
90
|
-
return /\/products\/([^/?#]+)/.exec(value)?.[1] ?? value;
|
|
91
|
-
}
|
package/src/status.ts
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Where a product scope gets its key from, and the state of a scope (loading, ready, error, not found), so a failing
|
|
3
|
-
* API never leaves a broken page.
|
|
4
|
-
*/
|
|
5
|
-
import { attr, sel, get, scoped, show } from './dom.ts';
|
|
6
|
-
|
|
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
|
-
}
|
|
15
|
-
|
|
16
|
-
type ScopeStatus = 'loading' | 'ready' | 'error' | 'not-found';
|
|
17
|
-
|
|
18
|
-
const URL_SOURCE = 'url:';
|
|
19
|
-
|
|
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 {
|
|
22
|
-
try {
|
|
23
|
-
return decodeURIComponent(segment);
|
|
24
|
-
} catch (error) {
|
|
25
|
-
if (error instanceof URIError) return segment;
|
|
26
|
-
throw error;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Reads a `tether-product` value:
|
|
32
|
-
* - `coffee-box`: a handle, numeric id, gid or product URL;
|
|
33
|
-
* - `url:handle`: `?handle=` from the page URL (any parameter: `url:p` reads `?p=`);
|
|
34
|
-
* - `url:path`: the last segment of the page path (`/shop/coffee-box` → `coffee-box`).
|
|
35
|
-
*
|
|
36
|
-
* @param name The attribute, without prefix.
|
|
37
|
-
*/
|
|
38
|
-
export function resolveProductKey(
|
|
39
|
-
el: Element,
|
|
40
|
-
location: Pick<Location, 'pathname' | 'search'> = window.location,
|
|
41
|
-
name = 'product',
|
|
42
|
-
): ProductKeySource {
|
|
43
|
-
const raw = (get(el, name) ?? '').trim();
|
|
44
|
-
if (!raw.toLowerCase().startsWith(URL_SOURCE)) return { key: raw || null, fromUrl: false, raw };
|
|
45
|
-
const source = raw.slice(URL_SOURCE.length).trim();
|
|
46
|
-
let key;
|
|
47
|
-
if (source.toLowerCase() === 'path') {
|
|
48
|
-
const segments = location.pathname.split('/').filter(Boolean);
|
|
49
|
-
const segment = segments[segments.length - 1];
|
|
50
|
-
key = segment === undefined ? null : decodePathSegment(segment);
|
|
51
|
-
} else {
|
|
52
|
-
key = new URLSearchParams(location.search).get(source || 'handle');
|
|
53
|
-
}
|
|
54
|
-
return { key: key?.trim() || null, fromUrl: true, raw };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** The values of `tether-show` that follow the scope's state. */
|
|
58
|
-
export const STATUS_KEYS: readonly ScopeStatus[] = ['loading', 'ready', 'error', 'not-found'];
|
|
59
|
-
|
|
60
|
-
const isScopeStatus = (value: unknown): value is ScopeStatus => STATUS_KEYS.some((status) => status === value);
|
|
61
|
-
|
|
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;
|
|
65
|
-
|
|
66
|
-
/** @returns True for a `TetherError` with code `NOT_FOUND`. */
|
|
67
|
-
export const isNotFound = (error: unknown): boolean => codeOf(error) === 'NOT_FOUND';
|
|
68
|
-
|
|
69
|
-
/** @returns The selector of every scope that owns its state elements; the nearest one wins. */
|
|
70
|
-
export const boundary = (): string =>
|
|
71
|
-
['product', 'collection', 'products', 'recommendations'].map((name) => sel(name)).join(',');
|
|
72
|
-
|
|
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;
|
|
76
|
-
|
|
77
|
-
/** @returns The error's code for `tether-error`, `error` without one. */
|
|
78
|
-
function errorCode(error: unknown): string {
|
|
79
|
-
const code = codeOf(error);
|
|
80
|
-
return typeof code === 'string' && code ? code : 'error';
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Sets `tether-state` (loading | ready | error) and `tether-error` (not-found or an error code) on `root`, toggles
|
|
85
|
-
* `tether-show="loading|ready|error|not-found"` inside it, and disables its add-to-cart buttons when there's no
|
|
86
|
-
* product.
|
|
87
|
-
*/
|
|
88
|
-
export function setScopeStatus(root: Element, status: ScopeStatus, error?: unknown): void {
|
|
89
|
-
const notFound = status === 'not-found';
|
|
90
|
-
const failed = status === 'error' || notFound;
|
|
91
|
-
root.setAttribute(attr('state'), notFound ? 'error' : status);
|
|
92
|
-
if (failed) root.setAttribute(attr('error'), notFound ? 'not-found' : errorCode(error));
|
|
93
|
-
else root.removeAttribute(attr('error'));
|
|
94
|
-
|
|
95
|
-
scoped(root, sel('show'), boundary()).forEach((el) => {
|
|
96
|
-
const shows = get(el, 'show');
|
|
97
|
-
if (isScopeStatus(shows)) show(el, shows === status);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
if (!failed) return;
|
|
101
|
-
scoped(root, sel('add-to-cart'), boundary()).forEach((button) => {
|
|
102
|
-
button.classList.add('is-disabled');
|
|
103
|
-
button.setAttribute('aria-disabled', 'true');
|
|
104
|
-
if ('disabled' in button) button.disabled = true;
|
|
105
|
-
});
|
|
106
|
-
}
|
package/src/storage.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
}
|