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 }
}