@shopify/shop-minis-react 0.4.15 → 0.4.17

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.
Files changed (50) hide show
  1. package/dist/components/atoms/alert-dialog.js.map +1 -1
  2. package/dist/components/atoms/button.js.map +1 -1
  3. package/dist/components/atoms/icon-button.js.map +1 -1
  4. package/dist/components/atoms/image.js +65 -51
  5. package/dist/components/atoms/image.js.map +1 -1
  6. package/dist/components/atoms/list.js.map +1 -1
  7. package/dist/components/atoms/text-input.js.map +1 -1
  8. package/dist/components/atoms/touchable.js.map +1 -1
  9. package/dist/components/atoms/video-player.js +1 -1
  10. package/dist/components/atoms/video-player.js.map +1 -1
  11. package/dist/components/commerce/add-to-cart.js.map +1 -1
  12. package/dist/components/commerce/buy-now.js.map +1 -1
  13. package/dist/components/commerce/favorite-button.js +1 -4
  14. package/dist/components/commerce/favorite-button.js.map +1 -1
  15. package/dist/components/commerce/merchant-card.js.map +1 -1
  16. package/dist/components/commerce/product-link.js.map +1 -1
  17. package/dist/components/commerce/quantity-selector.js.map +1 -1
  18. package/dist/components/content/image-content-wrapper.js.map +1 -1
  19. package/dist/components/navigation/minis-router.js.map +1 -1
  20. package/dist/components/navigation/transition-link.js.map +1 -1
  21. package/dist/components/ui/alert.js.map +1 -1
  22. package/dist/components/ui/badge.js.map +1 -1
  23. package/dist/components/ui/input.js.map +1 -1
  24. package/dist/index.js +75 -73
  25. package/dist/utils/image.js +44 -24
  26. package/dist/utils/image.js.map +1 -1
  27. package/eslint/rules/validate-manifest.cjs +91 -41
  28. package/package.json +1 -1
  29. package/src/components/atoms/alert-dialog.tsx +3 -3
  30. package/src/components/atoms/button.tsx +22 -0
  31. package/src/components/atoms/icon-button.tsx +16 -8
  32. package/src/components/atoms/image.test.tsx +27 -13
  33. package/src/components/atoms/image.tsx +41 -8
  34. package/src/components/atoms/list.tsx +25 -2
  35. package/src/components/atoms/text-input.tsx +3 -1
  36. package/src/components/atoms/touchable.tsx +15 -4
  37. package/src/components/atoms/video-player.tsx +16 -6
  38. package/src/components/commerce/add-to-cart.tsx +7 -11
  39. package/src/components/commerce/buy-now.tsx +7 -10
  40. package/src/components/commerce/favorite-button.tsx +6 -5
  41. package/src/components/commerce/merchant-card.tsx +4 -0
  42. package/src/components/commerce/product-link.tsx +15 -0
  43. package/src/components/commerce/quantity-selector.tsx +6 -1
  44. package/src/components/content/image-content-wrapper.tsx +16 -1
  45. package/src/components/navigation/minis-router.tsx +2 -2
  46. package/src/components/navigation/transition-link.tsx +11 -1
  47. package/src/components/ui/alert.tsx +7 -0
  48. package/src/components/ui/badge.tsx +9 -0
  49. package/src/components/ui/input.tsx +15 -0
  50. package/src/utils/image.ts +38 -0
@@ -1 +1 @@
1
- {"version":3,"file":"image.js","sources":["../../src/utils/image.ts"],"sourcesContent":["import {toUint8Array} from 'js-base64'\nimport {thumbHashToDataURL} from 'thumbhash'\n\n/**\n * Converts a thumbhash string to a data URL for use as an image placeholder\n * @param thumbhash Base64 encoded thumbhash string\n * @returns Data URL that can be used as image source or undefined if conversion fails\n */\nexport function getThumbhashDataURL(thumbhash?: string): string | undefined {\n if (!thumbhash) return\n\n try {\n const thumbhashArray = toUint8Array(thumbhash)\n return thumbHashToDataURL(thumbhashArray)\n } catch (error) {\n console.warn('Failed to decode thumbhash to data URL', error)\n return undefined\n }\n}\n\n/** Converts a file to a data URI\n * @param file The file to convert\n * @returns A promise that resolves to the data URI string\n */\nexport function fileToDataUri(file: File): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader()\n reader.onloadend = () => resolve(reader.result as string)\n reader.onerror = reject\n reader.readAsDataURL(file)\n })\n}\n\nconst ImageSizes = {\n xxsUrl: 32,\n xsUrl: 64,\n sUrl: 128,\n xxsmUrl: 256,\n xsmUrl: 384,\n smUrl: 512,\n mUrl: 640,\n lUrl: 1080,\n xlUrl: 2048,\n} as const\n\ntype Key = keyof typeof ImageSizes\n\n/**\n * Acceptable offset for image sizes. An image could use the size that is within this offset.\n */\nconst offsetPercentage = 0.05\n\nconst sortedImageSizes = Object.entries(ImageSizes).sort(\n ([, firstSize], [, secondSize]) => firstSize - secondSize\n)\n\nconst getImageSizeKeyWithSize = (size: number): Key => {\n for (const [key, imgSize] of sortedImageSizes) {\n const upperBoundSize = imgSize + imgSize * offsetPercentage\n if (size <= upperBoundSize) return key as Key\n }\n\n return 'xlUrl'\n}\n\nconst resizeImage = (imageUrl: string, width: number) => {\n const pattern = new RegExp(/\\?+/g)\n const delimiter = pattern.test(imageUrl) ? '&' : '?'\n return `${imageUrl}${delimiter}width=${width}`\n}\n\n/**\n * Optimizes Shopify CDN image URLs by adding a width parameter based on screen size\n * @param url The image URL to optimize\n * @returns The optimized URL with width parameter if it's a Shopify CDN image, otherwise returns the original URL\n */\n\nexport const getResizedImageUrl = (url?: string): string => {\n if (!url) return ''\n\n // Only process Shopify CDN images\n if (!url.startsWith('https://cdn.shopify.com')) {\n return url\n }\n\n const width = window.innerWidth ?? screen.width\n\n const key = getImageSizeKeyWithSize(width)\n\n return resizeImage(url, ImageSizes[key])\n}\n"],"names":["getThumbhashDataURL","thumbhash","thumbhashArray","toUint8Array","thumbHashToDataURL","error","fileToDataUri","file","resolve","reject","reader","ImageSizes","offsetPercentage","sortedImageSizes","firstSize","secondSize","getImageSizeKeyWithSize","size","key","imgSize","upperBoundSize","resizeImage","imageUrl","width","delimiter","getResizedImageUrl","url"],"mappings":";;AAQO,SAASA,EAAoBC,GAAwC;AAC1E,MAAKA;AAED,QAAA;AACI,YAAAC,IAAiBC,EAAaF,CAAS;AAC7C,aAAOG,EAAmBF,CAAc;AAAA,aACjCG,GAAO;AACN,cAAA,KAAK,0CAA0CA,CAAK;AACrD;AAAA,IAAA;AAEX;AAMO,SAASC,EAAcC,GAA6B;AACzD,SAAO,IAAI,QAAQ,CAACC,GAASC,MAAW;AAChC,UAAAC,IAAS,IAAI,WAAW;AAC9B,IAAAA,EAAO,YAAY,MAAMF,EAAQE,EAAO,MAAgB,GACxDA,EAAO,UAAUD,GACjBC,EAAO,cAAcH,CAAI;AAAA,EAAA,CAC1B;AACH;AAEA,MAAMI,IAAa;AAAA,EACjB,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,MAAM;AAAA,EACN,OAAO;AACT,GAOMC,IAAmB,MAEnBC,IAAmB,OAAO,QAAQF,CAAU,EAAE;AAAA,EAClD,CAAC,CAAG,EAAAG,CAAS,GAAG,GAAGC,CAAU,MAAMD,IAAYC;AACjD,GAEMC,IAA0B,CAACC,MAAsB;AACrD,aAAW,CAACC,GAAKC,CAAO,KAAKN,GAAkB;AACvC,UAAAO,IAAiBD,IAAUA,IAAUP;AACvC,QAAAK,KAAQG,EAAuB,QAAAF;AAAA,EAAA;AAG9B,SAAA;AACT,GAEMG,IAAc,CAACC,GAAkBC,MAAkB;AAEvD,QAAMC,IADU,IAAI,OAAO,MAAM,EACP,KAAKF,CAAQ,IAAI,MAAM;AACjD,SAAO,GAAGA,CAAQ,GAAGE,CAAS,SAASD,CAAK;AAC9C,GAQaE,IAAqB,CAACC,MAAyB;AACtD,MAAA,CAACA,EAAY,QAAA;AAGjB,MAAI,CAACA,EAAI,WAAW,yBAAyB;AACpC,WAAAA;AAGH,QAAAH,IAAQ,OAAO,cAAc,OAAO,OAEpCL,IAAMF,EAAwBO,CAAK;AAEzC,SAAOF,EAAYK,GAAKf,EAAWO,CAAG,CAAC;AACzC;"}
1
+ {"version":3,"file":"image.js","sources":["../../src/utils/image.ts"],"sourcesContent":["import {toUint8Array} from 'js-base64'\nimport {thumbHashToDataURL} from 'thumbhash'\n\n/**\n * Converts a thumbhash string to a data URL for use as an image placeholder\n * @param thumbhash Base64 encoded thumbhash string\n * @returns Data URL that can be used as image source or undefined if conversion fails\n */\nexport function getThumbhashDataURL(thumbhash?: string): string | undefined {\n if (!thumbhash) return\n\n try {\n const thumbhashArray = toUint8Array(thumbhash)\n return thumbHashToDataURL(thumbhashArray)\n } catch (error) {\n console.warn('Failed to decode thumbhash to data URL', error)\n return undefined\n }\n}\n\n/**\n * Converts a data URL to a Blob\n * @param dataURL The data URL to convert\n * @returns A Blob object\n */\nexport function dataURLToBlob(dataURL: string): Blob {\n const [header, base64Data] = dataURL.split(',')\n const mimeMatch = header.match(/:(.*?);/)\n const mime = mimeMatch ? mimeMatch[1] : 'image/png'\n const binary = atob(base64Data)\n const array = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i++) {\n array[i] = binary.charCodeAt(i)\n }\n return new Blob([array], {type: mime})\n}\n\n/**\n * Converts a thumbhash string to a blob URL for use as an image placeholder\n * This is useful when CSP restrictions prevent data URLs\n * @param thumbhash Base64 encoded thumbhash string\n * @returns Blob URL that can be used as image source or undefined if conversion fails\n */\nexport function getThumbhashBlobURL(thumbhash?: string): string | undefined {\n if (!thumbhash) return\n\n try {\n const dataURL = getThumbhashDataURL(thumbhash)\n if (!dataURL) return\n\n const blob = dataURLToBlob(dataURL)\n return URL.createObjectURL(blob)\n } catch (error) {\n console.warn('Failed to create thumbhash blob URL', error)\n return undefined\n }\n}\n\n/** Converts a file to a data URI\n * @param file The file to convert\n * @returns A promise that resolves to the data URI string\n */\nexport function fileToDataUri(file: File): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader()\n reader.onloadend = () => resolve(reader.result as string)\n reader.onerror = reject\n reader.readAsDataURL(file)\n })\n}\n\nconst ImageSizes = {\n xxsUrl: 32,\n xsUrl: 64,\n sUrl: 128,\n xxsmUrl: 256,\n xsmUrl: 384,\n smUrl: 512,\n mUrl: 640,\n lUrl: 1080,\n xlUrl: 2048,\n} as const\n\ntype Key = keyof typeof ImageSizes\n\n/**\n * Acceptable offset for image sizes. An image could use the size that is within this offset.\n */\nconst offsetPercentage = 0.05\n\nconst sortedImageSizes = Object.entries(ImageSizes).sort(\n ([, firstSize], [, secondSize]) => firstSize - secondSize\n)\n\nconst getImageSizeKeyWithSize = (size: number): Key => {\n for (const [key, imgSize] of sortedImageSizes) {\n const upperBoundSize = imgSize + imgSize * offsetPercentage\n if (size <= upperBoundSize) return key as Key\n }\n\n return 'xlUrl'\n}\n\nconst resizeImage = (imageUrl: string, width: number) => {\n const pattern = new RegExp(/\\?+/g)\n const delimiter = pattern.test(imageUrl) ? '&' : '?'\n return `${imageUrl}${delimiter}width=${width}`\n}\n\n/**\n * Optimizes Shopify CDN image URLs by adding a width parameter based on screen size\n * @param url The image URL to optimize\n * @returns The optimized URL with width parameter if it's a Shopify CDN image, otherwise returns the original URL\n */\n\nexport const getResizedImageUrl = (url?: string): string => {\n if (!url) return ''\n\n // Only process Shopify CDN images\n if (!url.startsWith('https://cdn.shopify.com')) {\n return url\n }\n\n const width = window.innerWidth ?? screen.width\n\n const key = getImageSizeKeyWithSize(width)\n\n return resizeImage(url, ImageSizes[key])\n}\n"],"names":["getThumbhashDataURL","thumbhash","thumbhashArray","toUint8Array","thumbHashToDataURL","error","dataURLToBlob","dataURL","header","base64Data","mimeMatch","mime","binary","array","i","getThumbhashBlobURL","blob","fileToDataUri","file","resolve","reject","reader","ImageSizes","offsetPercentage","sortedImageSizes","firstSize","secondSize","getImageSizeKeyWithSize","size","key","imgSize","upperBoundSize","resizeImage","imageUrl","width","delimiter","getResizedImageUrl","url"],"mappings":";;AAQO,SAASA,EAAoBC,GAAwC;AAC1E,MAAKA;AAED,QAAA;AACI,YAAAC,IAAiBC,EAAaF,CAAS;AAC7C,aAAOG,EAAmBF,CAAc;AAAA,aACjCG,GAAO;AACN,cAAA,KAAK,0CAA0CA,CAAK;AACrD;AAAA,IAAA;AAEX;AAOO,SAASC,EAAcC,GAAuB;AACnD,QAAM,CAACC,GAAQC,CAAU,IAAIF,EAAQ,MAAM,GAAG,GACxCG,IAAYF,EAAO,MAAM,SAAS,GAClCG,IAAOD,IAAYA,EAAU,CAAC,IAAI,aAClCE,IAAS,KAAKH,CAAU,GACxBI,IAAQ,IAAI,WAAWD,EAAO,MAAM;AAC1C,WAASE,IAAI,GAAGA,IAAIF,EAAO,QAAQE;AACjC,IAAAD,EAAMC,CAAC,IAAIF,EAAO,WAAWE,CAAC;AAEzB,SAAA,IAAI,KAAK,CAACD,CAAK,GAAG,EAAC,MAAMF,GAAK;AACvC;AAQO,SAASI,EAAoBd,GAAwC;AAC1E,MAAKA;AAED,QAAA;AACI,YAAAM,IAAUP,EAAoBC,CAAS;AAC7C,UAAI,CAACM,EAAS;AAER,YAAAS,IAAOV,EAAcC,CAAO;AAC3B,aAAA,IAAI,gBAAgBS,CAAI;AAAA,aACxBX,GAAO;AACN,cAAA,KAAK,uCAAuCA,CAAK;AAClD;AAAA,IAAA;AAEX;AAMO,SAASY,EAAcC,GAA6B;AACzD,SAAO,IAAI,QAAQ,CAACC,GAASC,MAAW;AAChC,UAAAC,IAAS,IAAI,WAAW;AAC9B,IAAAA,EAAO,YAAY,MAAMF,EAAQE,EAAO,MAAgB,GACxDA,EAAO,UAAUD,GACjBC,EAAO,cAAcH,CAAI;AAAA,EAAA,CAC1B;AACH;AAEA,MAAMI,IAAa;AAAA,EACjB,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,MAAM;AAAA,EACN,OAAO;AACT,GAOMC,IAAmB,MAEnBC,IAAmB,OAAO,QAAQF,CAAU,EAAE;AAAA,EAClD,CAAC,CAAG,EAAAG,CAAS,GAAG,GAAGC,CAAU,MAAMD,IAAYC;AACjD,GAEMC,IAA0B,CAACC,MAAsB;AACrD,aAAW,CAACC,GAAKC,CAAO,KAAKN,GAAkB;AACvC,UAAAO,IAAiBD,IAAUA,IAAUP;AACvC,QAAAK,KAAQG,EAAuB,QAAAF;AAAA,EAAA;AAG9B,SAAA;AACT,GAEMG,IAAc,CAACC,GAAkBC,MAAkB;AAEvD,QAAMC,IADU,IAAI,OAAO,MAAM,EACP,KAAKF,CAAQ,IAAI,MAAM;AACjD,SAAO,GAAGA,CAAQ,GAAGE,CAAS,SAASD,CAAK;AAC9C,GAQaE,IAAqB,CAACC,MAAyB;AACtD,MAAA,CAACA,EAAY,QAAA;AAGjB,MAAI,CAACA,EAAI,WAAW,yBAAyB;AACpC,WAAAA;AAGH,QAAAH,IAAQ,OAAO,cAAc,OAAO,OAEpCL,IAAMF,EAAwBO,CAAK;AAEzC,SAAOF,EAAYK,GAAKf,EAAWO,CAAG,CAAC;AACzC;"}
@@ -31,8 +31,8 @@ const hookPermissionsMap = {
31
31
  useImagePicker: ['CAMERA'],
32
32
  }
33
33
 
34
- // Extract domain from URL (returns domain in CSP format)
35
- function extractDomain(url) {
34
+ // Extract domain and path from URL (returns {hostname, pathname} for CSP matching)
35
+ function extractDomainAndPath(url) {
36
36
  if (!url || typeof url !== 'string') return null
37
37
 
38
38
  try {
@@ -48,25 +48,65 @@ function extractDomain(url) {
48
48
 
49
49
  // Parse URL
50
50
  const urlObj = new URL(url)
51
- return urlObj.hostname
51
+ return {
52
+ hostname: urlObj.hostname,
53
+ pathname: urlObj.pathname,
54
+ }
52
55
  } catch (err) {
53
56
  // If URL parsing fails, might be a relative path
54
57
  return null
55
58
  }
56
59
  }
57
60
 
58
- // Check if domain matches a pattern (supports wildcards)
59
- function domainMatchesPattern(domain, pattern) {
61
+ // Check if a URL matches a trusted_domain pattern
62
+ // Supports: wildcards (*.example.com), paths with CSP semantics
63
+ // CSP path matching: path with trailing slash matches directory, without matches exact or prefix
64
+ function urlMatchesPattern(urlInfo, pattern) {
60
65
  if (pattern === '*') return true
61
- if (pattern === domain) return true
62
66
 
63
- // Handle wildcard subdomains (*.example.com)
64
- if (pattern.startsWith('*.')) {
65
- const basePattern = pattern.slice(2)
66
- return domain === basePattern || domain.endsWith(`.${basePattern}`)
67
+ // Parse the pattern to extract hostname and optional path
68
+ let patternHostname = pattern
69
+ let patternPath = null
70
+
71
+ // Check if pattern contains a path (has / after the domain)
72
+ const slashIndex = pattern.indexOf('/')
73
+ if (slashIndex !== -1) {
74
+ patternHostname = pattern.slice(0, slashIndex)
75
+ patternPath = pattern.slice(slashIndex)
76
+ }
77
+
78
+ // First check hostname match
79
+ let hostnameMatches = false
80
+
81
+ if (patternHostname === urlInfo.hostname) {
82
+ hostnameMatches = true
83
+ } else if (patternHostname.startsWith('*.')) {
84
+ // Handle wildcard subdomains (*.example.com)
85
+ const basePattern = patternHostname.slice(2)
86
+ hostnameMatches =
87
+ urlInfo.hostname === basePattern ||
88
+ urlInfo.hostname.endsWith(`.${basePattern}`)
67
89
  }
68
90
 
69
- return false
91
+ if (!hostnameMatches) {
92
+ return false
93
+ }
94
+
95
+ // If no path in pattern, hostname match is sufficient
96
+ if (!patternPath) {
97
+ return true
98
+ }
99
+
100
+ // CSP path matching semantics:
101
+ // - Path ending with / matches that directory and everything under it (prefix match)
102
+ // - Path without trailing / matches exact path only
103
+ if (patternPath.endsWith('/')) {
104
+ // Directory match: URL path must start with pattern path
105
+ return urlInfo.pathname.startsWith(patternPath)
106
+ } else {
107
+ // Exact match only
108
+ return urlInfo.pathname === patternPath
109
+ }
70
110
  }
71
111
 
72
112
  module.exports = {
@@ -308,10 +348,11 @@ module.exports = {
308
348
  // Check for fetch() calls
309
349
  if (node.callee.name === 'fetch' && firstArg.type === 'Literal') {
310
350
  const url = firstArg.value
311
- const domain = extractDomain(url)
312
- if (domain) {
351
+ const urlInfo = extractDomainAndPath(url)
352
+ if (urlInfo) {
313
353
  requiredDomains.add({
314
- domain,
354
+ urlInfo,
355
+ url,
315
356
  reason: 'fetch() call',
316
357
  node,
317
358
  })
@@ -326,10 +367,11 @@ module.exports = {
326
367
  node.arguments[1].type === 'Literal'
327
368
  ) {
328
369
  const url = node.arguments[1].value
329
- const domain = extractDomain(url)
330
- if (domain) {
370
+ const urlInfo = extractDomainAndPath(url)
371
+ if (urlInfo) {
331
372
  requiredDomains.add({
332
- domain,
373
+ urlInfo,
374
+ url,
333
375
  reason: 'XMLHttpRequest.open() call',
334
376
  node,
335
377
  })
@@ -340,11 +382,12 @@ module.exports = {
340
382
  if (node.callee.name === 'WebSocket' && firstArg.type === 'Literal') {
341
383
  const url = firstArg.value
342
384
  // WebSocket URLs use ws:// or wss://
343
- const domain = url.replace(/^wss?:\/\//, 'https://')
344
- const extracted = extractDomain(domain)
345
- if (extracted) {
385
+ const normalizedUrl = url.replace(/^wss?:\/\//, 'https://')
386
+ const urlInfo = extractDomainAndPath(normalizedUrl)
387
+ if (urlInfo) {
346
388
  requiredDomains.add({
347
- domain: extracted,
389
+ urlInfo,
390
+ url,
348
391
  reason: 'WebSocket connection',
349
392
  node,
350
393
  })
@@ -354,10 +397,11 @@ module.exports = {
354
397
  // Check for EventSource
355
398
  if (node.callee.name === 'EventSource' && firstArg.type === 'Literal') {
356
399
  const url = firstArg.value
357
- const domain = extractDomain(url)
358
- if (domain) {
400
+ const urlInfo = extractDomainAndPath(url)
401
+ if (urlInfo) {
359
402
  requiredDomains.add({
360
- domain,
403
+ urlInfo,
404
+ url,
361
405
  reason: 'EventSource connection',
362
406
  node,
363
407
  })
@@ -371,10 +415,11 @@ module.exports = {
371
415
  firstArg.type === 'Literal'
372
416
  ) {
373
417
  const url = firstArg.value
374
- const domain = extractDomain(url)
375
- if (domain) {
418
+ const urlInfo = extractDomainAndPath(url)
419
+ if (urlInfo) {
376
420
  requiredDomains.add({
377
- domain,
421
+ urlInfo,
422
+ url,
378
423
  reason: 'navigator.sendBeacon() call',
379
424
  node,
380
425
  })
@@ -389,10 +434,11 @@ module.exports = {
389
434
  firstArg.type === 'Literal'
390
435
  ) {
391
436
  const url = firstArg.value
392
- const domain = extractDomain(url)
393
- if (domain) {
437
+ const urlInfo = extractDomainAndPath(url)
438
+ if (urlInfo) {
394
439
  requiredDomains.add({
395
- domain,
440
+ urlInfo,
441
+ url,
396
442
  reason: 'window.open() call',
397
443
  node,
398
444
  })
@@ -499,9 +545,9 @@ module.exports = {
499
545
 
500
546
  const attrName = node.name.name
501
547
  const url = node.value.value
502
- const domain = extractDomain(url)
548
+ const urlInfo = extractDomainAndPath(url)
503
549
 
504
- if (!domain) {
550
+ if (!urlInfo) {
505
551
  return
506
552
  }
507
553
 
@@ -522,7 +568,8 @@ module.exports = {
522
568
  ]
523
569
  if (supportedElements.includes(elementName)) {
524
570
  requiredDomains.add({
525
- domain,
571
+ urlInfo,
572
+ url,
526
573
  reason: `<${elementName}> src attribute`,
527
574
  node,
528
575
  })
@@ -531,7 +578,8 @@ module.exports = {
531
578
  // poster attribute (video)
532
579
  else if (attrName === 'poster') {
533
580
  requiredDomains.add({
534
- domain,
581
+ urlInfo,
582
+ url,
535
583
  reason: `<${elementName}> poster attribute`,
536
584
  node,
537
585
  })
@@ -539,7 +587,8 @@ module.exports = {
539
587
  // data attribute (object)
540
588
  else if (attrName === 'data') {
541
589
  requiredDomains.add({
542
- domain,
590
+ urlInfo,
591
+ url,
543
592
  reason: `<${elementName}> data attribute`,
544
593
  node,
545
594
  })
@@ -547,7 +596,8 @@ module.exports = {
547
596
  // action attribute (form)
548
597
  else if (attrName === 'action') {
549
598
  requiredDomains.add({
550
- domain,
599
+ urlInfo,
600
+ url,
551
601
  reason: `<${elementName}> action attribute`,
552
602
  node,
553
603
  })
@@ -692,26 +742,26 @@ module.exports = {
692
742
  })
693
743
 
694
744
  // Check trusted domains
695
- requiredDomains.forEach(({domain, reason, node}) => {
745
+ requiredDomains.forEach(({urlInfo, url, reason, node}) => {
696
746
  if (!manifest) {
697
747
  issues.push({
698
748
  messageId: 'manifestNotFound',
699
- data: {reason, domain},
749
+ data: {reason, domain: urlInfo.hostname},
700
750
  })
701
751
  return
702
752
  }
703
753
 
704
754
  const trustedDomains = manifest.trusted_domains || []
705
755
 
706
- // Check if domain is trusted (supports wildcards)
756
+ // Check if URL is trusted (supports wildcards and paths)
707
757
  const isTrusted = trustedDomains.some(pattern =>
708
- domainMatchesPattern(domain, pattern)
758
+ urlMatchesPattern(urlInfo, pattern)
709
759
  )
710
760
 
711
761
  if (!isTrusted) {
712
762
  issues.push({
713
763
  type: 'domain',
714
- domain,
764
+ domain: urlInfo.hostname,
715
765
  reason,
716
766
  node,
717
767
  })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shopify/shop-minis-react",
3
3
  "license": "SEE LICENSE IN LICENSE.txt",
4
- "version": "0.4.15",
4
+ "version": "0.4.17",
5
5
  "sideEffects": false,
6
6
  "type": "module",
7
7
  "engines": {
@@ -20,11 +20,11 @@ export interface AlertDialogAtomProps {
20
20
  /** The description text shown in the alert dialog body */
21
21
  description: string
22
22
  /** The text shown in the cancel button */
23
- cancelButtonText: string
23
+ cancelButtonText?: string
24
24
  /** The text shown in the confirmation button */
25
- confirmationButtonText: string
25
+ confirmationButtonText?: string
26
26
  /** Whether the alert dialog is open */
27
- open: boolean
27
+ open?: boolean
28
28
  /** Callback fired when the alert dialog open state changes */
29
29
  onOpenChange: (open: boolean) => void
30
30
  /** Callback fired when the confirmation button is clicked */
@@ -7,6 +7,28 @@ import {BaseButton, buttonVariants} from '../ui/button'
7
7
 
8
8
  import {Touchable} from './touchable'
9
9
 
10
+ export interface ButtonDocProps {
11
+ /** Visual style variant */
12
+ variant?:
13
+ | 'default'
14
+ | 'secondary'
15
+ | 'destructive'
16
+ | 'outline'
17
+ | 'ghost'
18
+ | 'link'
19
+ | 'icon'
20
+ /** Button size */
21
+ size?: 'default' | 'sm' | 'lg' | 'icon'
22
+ /** Click handler */
23
+ onClick?: React.MouseEventHandler<HTMLButtonElement>
24
+ /** Prevent click from bubbling to parent elements */
25
+ stopPropagation?: boolean
26
+ /** Whether the button is disabled */
27
+ disabled?: boolean
28
+ /** Button content */
29
+ children?: React.ReactNode
30
+ }
31
+
10
32
  export function Button({
11
33
  className,
12
34
  variant,
@@ -4,6 +4,21 @@ import {cn} from '../../lib/utils'
4
4
 
5
5
  import {Button} from './button'
6
6
 
7
+ export interface IconButtonProps {
8
+ /** Click handler */
9
+ onClick?: () => void
10
+ /** Whether the button is in a filled/active state */
11
+ filled?: boolean
12
+ /** Button size variant */
13
+ size?: 'default' | 'sm' | 'lg'
14
+ /** Lucide icon component to render */
15
+ Icon: LucideIcon
16
+ /** Custom CSS classes for the button container */
17
+ buttonStyles?: string
18
+ /** Custom CSS classes for the icon */
19
+ iconStyles?: string
20
+ }
21
+
7
22
  export function IconButton({
8
23
  onClick,
9
24
  filled = false,
@@ -11,14 +26,7 @@ export function IconButton({
11
26
  Icon,
12
27
  buttonStyles,
13
28
  iconStyles,
14
- }: {
15
- onClick?: () => void
16
- filled?: boolean
17
- size?: 'default' | 'sm' | 'lg'
18
- Icon: LucideIcon
19
- buttonStyles?: string
20
- iconStyles?: string
21
- }) {
29
+ }: IconButtonProps) {
22
30
  const sizeMap = {
23
31
  sm: 'size-3',
24
32
  default: 'size-4',
@@ -1,13 +1,18 @@
1
- import {describe, expect, it, vi} from 'vitest'
1
+ import {describe, expect, it, vi, beforeAll} from 'vitest'
2
2
 
3
3
  import {render, screen, waitFor} from '../../test-utils'
4
4
 
5
5
  import {Image} from './image'
6
6
 
7
+ // Mock URL.revokeObjectURL for jsdom
8
+ beforeAll(() => {
9
+ URL.revokeObjectURL = vi.fn()
10
+ })
11
+
7
12
  // Mock the util functions
8
13
  vi.mock('../../utils', () => ({
9
- getThumbhashDataURL: vi.fn((thumbhash?: string) =>
10
- thumbhash ? `data:image/png;base64,${thumbhash}` : null
14
+ getThumbhashBlobURL: vi.fn((thumbhash?: string) =>
15
+ thumbhash ? `blob:http://localhost/${thumbhash}` : undefined
11
16
  ),
12
17
  getResizedImageUrl: vi.fn((url?: string) => url),
13
18
  }))
@@ -76,7 +81,7 @@ describe('Image', () => {
76
81
  expect(wrapper.style.aspectRatio).toBe('16/9')
77
82
  })
78
83
 
79
- it('uses thumbhash as background when provided with fixed aspect ratio', () => {
84
+ it('uses thumbhash as placeholder when provided with fixed aspect ratio', () => {
80
85
  const {container} = render(
81
86
  <Image
82
87
  src="https://example.com/image.jpg"
@@ -86,10 +91,15 @@ describe('Image', () => {
86
91
  />
87
92
  )
88
93
 
89
- const wrapper = container.firstChild as HTMLElement
90
- expect(wrapper.style.backgroundImage).toContain(
91
- 'data:image/png;base64,testThumbhash'
94
+ // Thumbhash is now rendered as a separate img element (placeholder)
95
+ const images = container.querySelectorAll('img')
96
+ expect(images).toHaveLength(2) // placeholder + main image
97
+ const placeholderImg = images[0]
98
+ expect(placeholderImg).toHaveAttribute(
99
+ 'src',
100
+ 'blob:http://localhost/testThumbhash'
92
101
  )
102
+ expect(placeholderImg).toHaveAttribute('aria-hidden', 'true')
93
103
  })
94
104
 
95
105
  it('renders with natural sizing when aspectRatio is auto', () => {
@@ -122,13 +132,17 @@ describe('Image', () => {
122
132
  />
123
133
  )
124
134
 
125
- const wrapper = container.firstChild as HTMLElement
126
- const img = screen.getByRole('img')
127
-
128
- expect(wrapper.style.backgroundImage).toContain(
129
- 'data:image/png;base64,testThumbhash'
135
+ // Thumbhash is now rendered as a separate img element (placeholder)
136
+ const images = container.querySelectorAll('img')
137
+ expect(images).toHaveLength(2) // placeholder + main image
138
+ const placeholderImg = images[0]
139
+ expect(placeholderImg).toHaveAttribute(
140
+ 'src',
141
+ 'blob:http://localhost/testThumbhash'
130
142
  )
131
- expect(img).toHaveClass('w-full', 'h-auto')
143
+
144
+ const mainImg = images[1]
145
+ expect(mainImg).toHaveClass('w-full', 'h-auto')
132
146
  })
133
147
 
134
148
  it('passes additional props to img element', () => {
@@ -10,7 +10,24 @@ import {
10
10
  } from 'react'
11
11
 
12
12
  import {cn} from '../../lib/utils'
13
- import {getThumbhashDataURL, getResizedImageUrl} from '../../utils'
13
+ import {getThumbhashBlobURL, getResizedImageUrl} from '../../utils'
14
+
15
+ export interface ImageDocProps {
16
+ /** Remote image URL */
17
+ src?: string
18
+ /** File object from useImagePicker (auto-manages blob URL lifecycle) */
19
+ file?: File
20
+ /** Thumbhash string for progressive loading placeholder */
21
+ thumbhash?: string | null
22
+ /** Aspect ratio (e.g., 16/9, "4/3", or "auto") */
23
+ aspectRatio?: number | string
24
+ /** How the image should fit within its container */
25
+ objectFit?: 'cover' | 'contain' | 'fill' | 'scale-down' | 'none'
26
+ /** Alt text for accessibility */
27
+ alt?: string
28
+ /** Callback when image finishes loading */
29
+ onLoad?: () => void
30
+ }
14
31
 
15
32
  type ImageProps = ImgHTMLAttributes<HTMLImageElement> & {
16
33
  src?: string
@@ -52,11 +69,20 @@ export const Image = memo(function Image(props: ImageProps) {
52
69
  }
53
70
  }, [file])
54
71
 
55
- const thumbhashDataURL = useMemo(
56
- () => getThumbhashDataURL(thumbhash ?? undefined),
72
+ const thumbhashBlobUrl = useMemo(
73
+ () => getThumbhashBlobURL(thumbhash ?? undefined),
57
74
  [thumbhash]
58
75
  )
59
76
 
77
+ // Cleanup blob URL when it changes or component unmounts
78
+ useEffect(() => {
79
+ return () => {
80
+ if (thumbhashBlobUrl) {
81
+ URL.revokeObjectURL(thumbhashBlobUrl)
82
+ }
83
+ }
84
+ }, [thumbhashBlobUrl])
85
+
60
86
  const handleLoad = useCallback(
61
87
  (event: React.SyntheticEvent<HTMLImageElement, Event>) => {
62
88
  setIsLoaded(true)
@@ -77,13 +103,20 @@ export const Image = memo(function Image(props: ImageProps) {
77
103
  style={{
78
104
  ...style,
79
105
  ...(aspectRatio !== 'auto' && {aspectRatio}),
80
- backgroundImage: thumbhashDataURL
81
- ? `url(${thumbhashDataURL})`
82
- : undefined,
83
- backgroundSize: 'cover',
84
- backgroundPosition: 'center',
85
106
  }}
86
107
  >
108
+ {thumbhashBlobUrl && !isLoaded && (
109
+ <img
110
+ className={cn(
111
+ aspectRatio === 'auto'
112
+ ? 'w-full h-auto'
113
+ : 'absolute inset-0 size-full',
114
+ 'object-cover'
115
+ )}
116
+ src={thumbhashBlobUrl}
117
+ aria-hidden="true"
118
+ />
119
+ )}
87
120
  <img
88
121
  className={cn(
89
122
  aspectRatio === 'auto'
@@ -12,7 +12,30 @@ import {Skeleton} from '../ui/skeleton'
12
12
  const DEFAULT_REFRESH_PULL_THRESHOLD = 200
13
13
  const ELEMENT_BIND_DELAY = 100
14
14
 
15
- interface Props<T = any>
15
+ export interface ListDocProps<T = any> {
16
+ /** Array of items to render */
17
+ items: T[]
18
+ /** Function to render each item */
19
+ renderItem: (item: T, index: number) => React.ReactNode
20
+ /** Height of the list container */
21
+ height?: string | number
22
+ /** Show scrollbar (default: false) */
23
+ showScrollbar?: boolean
24
+ /** Header element rendered at the top of the list */
25
+ header?: React.ReactNode
26
+ /** Callback to fetch more items when scrolled to bottom */
27
+ fetchMore?: () => Promise<void>
28
+ /** Custom loading component shown while fetching more */
29
+ loadingComponent?: React.ReactNode
30
+ /** Callback for pull-to-refresh */
31
+ onRefresh?: () => Promise<void>
32
+ /** Whether the list is currently refreshing */
33
+ refreshing?: boolean
34
+ /** Enable pull-to-refresh gesture (default: true) */
35
+ enablePullToRefresh?: boolean
36
+ }
37
+
38
+ export interface ListProps<T = any>
16
39
  extends Omit<
17
40
  VirtuosoProps<T, unknown>,
18
41
  'data' | 'itemContent' | 'endReached'
@@ -41,7 +64,7 @@ export function List<T = any>({
41
64
  refreshing,
42
65
  enablePullToRefresh = true,
43
66
  ...virtuosoProps
44
- }: Props<T>) {
67
+ }: ListProps<T>) {
45
68
  const inFlightFetchMoreRef = useRef<Promise<void> | null>(null)
46
69
  const virtuosoRef = useRef<any>(null)
47
70
  const containerRef = useRef<HTMLDivElement>(null)
@@ -3,7 +3,9 @@ import * as React from 'react'
3
3
  import {useKeyboardAvoidingView} from '../../hooks'
4
4
  import {Input} from '../ui/input'
5
5
 
6
- function TextInput({...props}: React.ComponentProps<'input'>) {
6
+ export type TextInputProps = React.ComponentProps<'input'>
7
+
8
+ function TextInput({...props}: TextInputProps) {
7
9
  const inputRef = React.useRef<HTMLInputElement>(null)
8
10
  const {onBlur, onFocus} = useKeyboardAvoidingView()
9
11
 
@@ -2,15 +2,26 @@ import * as React from 'react'
2
2
 
3
3
  import {motion, HTMLMotionProps, useAnimationControls} from 'motion/react'
4
4
 
5
+ export interface TouchableDocProps {
6
+ /** Click handler */
7
+ onClick?: React.MouseEventHandler<HTMLDivElement>
8
+ /** Prevent click event from bubbling to parent elements */
9
+ stopPropagation?: boolean
10
+ /** Content to render inside the touchable area */
11
+ children?: React.ReactNode
12
+ }
13
+
14
+ export interface TouchableProps extends HTMLMotionProps<'div'> {
15
+ onClick?: React.MouseEventHandler<HTMLDivElement>
16
+ stopPropagation?: boolean
17
+ }
18
+
5
19
  export const Touchable = ({
6
20
  children,
7
21
  onClick,
8
22
  stopPropagation = false,
9
23
  ...props
10
- }: HTMLMotionProps<'div'> & {
11
- onClick?: React.MouseEventHandler<HTMLDivElement>
12
- stopPropagation?: boolean
13
- }) => {
24
+ }: TouchableProps) => {
14
25
  const ref = React.useRef<HTMLDivElement>(null)
15
26
  const controls = useAnimationControls()
16
27
 
@@ -17,24 +17,34 @@ export interface VideoPlayerRef {
17
17
  pause: () => void
18
18
  }
19
19
 
20
- interface VideoPlayerProps {
20
+ export interface VideoPlayerProps {
21
+ /** The video source URL */
21
22
  src: string
22
- /**
23
- * The format/MIME type of the video.
24
- * @default 'video/mp4'
25
- */
23
+ /** The format/MIME type of the video (default: 'video/mp4') */
26
24
  format?: string
25
+ /** Whether the video should be muted */
27
26
  muted?: boolean
27
+ /** URL for the poster image shown before playback */
28
28
  poster?: string
29
+ /** Whether the video should autoplay */
29
30
  autoplay?: boolean
31
+ /** Preload behavior: 'none', 'metadata', or 'auto' */
30
32
  preload?: 'none' | 'metadata' | 'auto'
33
+ /** Whether the video should loop */
31
34
  loop?: boolean
35
+ /** Video width in pixels */
32
36
  width?: number
37
+ /** Video height in pixels */
33
38
  height?: number
39
+ /** Custom play button component */
34
40
  playButtonComponent?: React.ReactNode
41
+ /** Callback when video starts playing */
35
42
  onPlay?: () => void
43
+ /** Callback when video is paused */
36
44
  onPause?: () => void
45
+ /** Callback when video ends */
37
46
  onEnded?: () => void
47
+ /** Callback when video player is ready */
38
48
  onReady?: () => void
39
49
  }
40
50
 
@@ -49,7 +59,7 @@ export const VideoPlayer: React.ForwardRefExoticComponent<
49
59
  muted,
50
60
  autoplay,
51
61
  preload = 'auto',
52
- loop = 'false',
62
+ loop = false,
53
63
  width,
54
64
  height,
55
65
  playButtonComponent,