export const DEFAULT_API_VERSION = '2026-07' export const MYSHOPIFY_HOST_SUFFIX = '.myshopify.com' const API_VERSION_RE = /^\d{4}-(0[1-9]|1[0-2])$/ const SHOP_HANDLE_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/ export function trimString(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } export function clampInt( value: unknown, min: number, max: number, fallback: number, ): number { const number = Number(value) if (!Number.isFinite(number)) return fallback return Math.min(max, Math.max(min, Math.floor(number))) } /** * Normalize a shop identifier to the myshopify.com handle (no host suffix). * Accepts `acme`, `acme.myshopify.com`, or `https://acme.myshopify.com/admin`. */ export function normalizeShop(value: unknown): string { let raw = trimString(value).toLowerCase() if (!raw) { throw new Error( 'Shop is required. Pass shop: "your-store" (the *.myshopify.com handle).', ) } if (raw.includes('://')) { try { raw = new URL(raw).hostname } catch { throw new Error(`Shop URL is invalid: ${String(value)}`) } } raw = raw.replace(/\/.*$/, '') if (raw.endsWith(MYSHOPIFY_HOST_SUFFIX)) { raw = raw.slice(0, -MYSHOPIFY_HOST_SUFFIX.length) } if (raw.includes('.') || !SHOP_HANDLE_RE.test(raw)) { throw new Error( `Shop must be a myshopify.com handle such as "your-store", not ${JSON.stringify(value)}.`, ) } return raw } export function shopHostname(shop: string): string { return `${normalizeShop(shop)}${MYSHOPIFY_HOST_SUFFIX}` } export function shopOrigin(shop: string): string { return `https://${shopHostname(shop)}` } export function resolveApiVersion(value?: unknown): string { const version = trimString(value) || DEFAULT_API_VERSION if (!API_VERSION_RE.test(version)) { throw new Error( `API version must look like "2026-07" (got ${JSON.stringify(value)}).`, ) } return version } /** Accept a GID or numeric/string id and return `gid://shopify/{type}/{id}`. */ export function toGid(type: string, id: unknown): string { const raw = trimString(id) if (!raw) throw new Error(`${type} id is required.`) if (raw.startsWith('gid://')) { if (!raw.startsWith(`gid://shopify/${type}/`)) { throw new Error(`Expected a gid://shopify/${type}/… id, got ${raw}`) } return raw } if (!/^\d+$/.test(raw)) { throw new Error(`${type} id must be a GID or numeric id, got ${JSON.stringify(id)}`) } return `gid://shopify/${type}/${raw}` } export function gidId(gid: string): string { const parts = gid.split('/') return parts[parts.length - 1] || gid } export type SelfCheckResult = { ok: true checks: number } /** Local, network-free assertions for shop/id helpers. Safe for publish smoke. */ export function runShopHelperSelfCheck(): SelfCheckResult { const shops: Array<[unknown, string]> = [ ['Acme-Store', 'acme-store'], ['acme-store.myshopify.com', 'acme-store'], ['https://acme-store.myshopify.com/admin', 'acme-store'], ['x', 'x'], ] for (const [input, expected] of shops) { const got = normalizeShop(input) if (got !== expected) { throw new Error(`normalizeShop(${JSON.stringify(input)}) => ${got}, expected ${expected}`) } } const rejected = ['https://evil.example', 'store.example.com', '../nope', ''] for (const input of rejected) { let threw = false try { normalizeShop(input) } catch { threw = true } if (!threw) throw new Error(`normalizeShop should reject ${JSON.stringify(input)}`) } if (shopHostname('acme') !== 'acme.myshopify.com') { throw new Error('shopHostname failed') } if (resolveApiVersion() !== DEFAULT_API_VERSION) { throw new Error('default API version drifted') } if (resolveApiVersion('2026-04') !== '2026-04') { throw new Error('resolveApiVersion failed') } let versionThrew = false try { resolveApiVersion('v1') } catch { versionThrew = true } if (!versionThrew) throw new Error('resolveApiVersion should reject v1') if (toGid('Product', '123') !== 'gid://shopify/Product/123') { throw new Error('toGid numeric failed') } if (toGid('Product', 'gid://shopify/Product/9') !== 'gid://shopify/Product/9') { throw new Error('toGid passthrough failed') } if (gidId('gid://shopify/Order/55') !== '55') { throw new Error('gidId failed') } return { ok: true, checks: shops.length + rejected.length + 7 } }