react-email 4.0.0-alpha.5 → 4.0.0-alpha.7

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 (203) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli/index.js +1179 -2659
  3. package/dist/cli/index.mjs +17 -11
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +32 -31
  6. package/dist/preview/.next/app-path-routes-manifest.json +6 -1
  7. package/dist/preview/.next/build-manifest.json +14 -14
  8. package/dist/preview/.next/cache/.rscinfo +1 -1
  9. package/dist/preview/.next/cache/webpack/client-production/0.pack +0 -0
  10. package/dist/preview/.next/cache/webpack/client-production/index.pack +0 -0
  11. package/dist/preview/.next/cache/webpack/edge-server-production/index.pack +0 -0
  12. package/dist/preview/.next/cache/webpack/server-production/0.pack +0 -0
  13. package/dist/preview/.next/cache/webpack/server-production/index.pack +0 -0
  14. package/dist/preview/.next/diagnostics/framework.json +1 -1
  15. package/dist/preview/.next/export-marker.json +6 -1
  16. package/dist/preview/.next/images-manifest.json +57 -1
  17. package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
  18. package/dist/preview/.next/next-server.js.nft.json +1 -1
  19. package/dist/preview/.next/prerender-manifest.json +41 -1
  20. package/dist/preview/.next/required-server-files.json +310 -1
  21. package/dist/preview/.next/routes-manifest.json +64 -1
  22. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  23. package/dist/preview/.next/server/app/_not-found/page.js.nft.json +1 -1
  24. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  25. package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
  26. package/dist/preview/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  27. package/dist/preview/.next/server/app/page.js +1 -1
  28. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  29. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  30. package/dist/preview/.next/server/app/preview/[...slug]/page.js +51 -11
  31. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  32. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  33. package/dist/preview/.next/server/app-paths-manifest.json +1 -1
  34. package/dist/preview/.next/server/chunks/446.js +6 -0
  35. package/dist/preview/.next/server/chunks/600.js +8 -0
  36. package/dist/preview/.next/server/chunks/811.js +13 -0
  37. package/dist/preview/.next/server/chunks/816.js +14 -0
  38. package/dist/preview/.next/server/chunks/943.js +1 -0
  39. package/dist/preview/.next/server/functions-config-manifest.json +4 -1
  40. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  41. package/dist/preview/.next/server/next-font-manifest.js +1 -1
  42. package/dist/preview/.next/server/next-font-manifest.json +1 -1
  43. package/dist/preview/.next/server/pages/500.html +1 -1
  44. package/dist/preview/.next/server/pages/_app.js +1 -1
  45. package/dist/preview/.next/server/pages/_app.js.nft.json +1 -1
  46. package/dist/preview/.next/server/pages/_document.js +1 -1
  47. package/dist/preview/.next/server/pages/_document.js.nft.json +1 -1
  48. package/dist/preview/.next/server/pages/_error.js +1 -1
  49. package/dist/preview/.next/server/pages/_error.js.nft.json +1 -1
  50. package/dist/preview/.next/server/pages-manifest.json +5 -1
  51. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  52. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  53. package/dist/preview/.next/server/webpack-runtime.js +1 -1
  54. package/dist/preview/.next/static/Pms2orsQgT5xpttCfZfH5/_buildManifest.js +1 -0
  55. package/dist/preview/.next/static/chunks/287-7864b805e6bdc854.js +1 -0
  56. package/dist/preview/.next/static/chunks/412-31817e53b50a3e73.js +1 -0
  57. package/dist/preview/.next/static/chunks/683-1fb40795502f6e63.js +1 -0
  58. package/dist/preview/.next/static/chunks/744-79730358b37b2212.js +1 -0
  59. package/dist/preview/.next/static/chunks/781-5f16c6bc9d9d4cc1.js +1 -0
  60. package/dist/preview/.next/static/chunks/832ad4be-cb988facfb8f955f.js +1 -0
  61. package/dist/preview/.next/static/chunks/880-9c0b721328117b8b.js +1 -0
  62. package/dist/preview/.next/static/chunks/{afa401a5-a600c227dacf3ab4.js → afa401a5-3e949a1cfd317dd3.js} +3 -3
  63. package/dist/preview/.next/static/chunks/app/_not-found/page-09d694081cc9d4dc.js +1 -0
  64. package/dist/preview/.next/static/chunks/app/layout-ffdee5cc1be30e7b.js +1 -0
  65. package/dist/preview/.next/static/chunks/app/page-9ea0bd45cd6294b0.js +1 -0
  66. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-9e22979a25c836c0.js +1 -0
  67. package/dist/preview/.next/static/chunks/framework-c2bd6d936e3077bc.js +1 -0
  68. package/dist/preview/.next/static/chunks/main-44463a8301435b64.js +1 -0
  69. package/dist/preview/.next/static/chunks/main-app-256b213b179a95cc.js +1 -0
  70. package/dist/preview/.next/static/chunks/pages/_app-f3011d3f00bb8dba.js +1 -0
  71. package/dist/preview/.next/static/chunks/pages/_error-39a87dee2e97a2a3.js +1 -0
  72. package/dist/preview/.next/static/chunks/{webpack-2eb145a20ee6cb77.js → webpack-41e2667c9f086a4f.js} +1 -1
  73. package/dist/preview/.next/static/css/eaae8ce545b295f9.css +3 -0
  74. package/dist/preview/.next/trace +26 -22
  75. package/dist/preview/.next/types/app/layout.ts +1 -1
  76. package/dist/preview/.next/types/app/page.ts +84 -0
  77. package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
  78. package/dist/preview/.next/types/cache-life.d.ts +3 -3
  79. package/package.json +14 -9
  80. package/scripts/build-preview-server.mjs +32 -0
  81. package/scripts/fill-caniemail-data.mjs +36 -0
  82. package/src/actions/email-validation/caniemail-data.ts +85993 -0
  83. package/src/actions/email-validation/check-compatibility.ts +321 -0
  84. package/src/actions/email-validation/check-images.spec.tsx +15 -13
  85. package/src/actions/email-validation/check-images.ts +8 -2
  86. package/src/actions/email-validation/check-links.spec.tsx +27 -15
  87. package/src/actions/email-validation/check-links.ts +8 -2
  88. package/src/actions/email-validation/get-code-location-from-ast-element.ts +18 -0
  89. package/src/actions/get-email-path-from-slug.ts +1 -1
  90. package/src/actions/render-email-by-path.tsx +2 -1
  91. package/src/{utils/emails-directory-absolute-path.ts → app/env.ts} +5 -0
  92. package/src/app/layout.tsx +1 -1
  93. package/src/app/page.tsx +1 -1
  94. package/src/app/preview/[...slug]/page.tsx +89 -19
  95. package/src/app/preview/[...slug]/preview.tsx +25 -68
  96. package/src/components/code-container.tsx +90 -71
  97. package/src/components/code.tsx +106 -43
  98. package/src/components/icons/icon-info.tsx +18 -0
  99. package/src/components/icons/icon-reload.tsx +13 -14
  100. package/src/components/logo.tsx +3 -2
  101. package/src/components/resizable-wrapper.tsx +1 -4
  102. package/src/components/sidebar/file-tree-directory-children.tsx +1 -0
  103. package/src/components/sidebar/sidebar.tsx +2 -3
  104. package/src/components/toolbar/code-preview-line-link.tsx +40 -0
  105. package/src/components/toolbar/compatibility.tsx +113 -0
  106. package/src/components/toolbar/linter.tsx +226 -125
  107. package/src/components/toolbar/results.tsx +5 -2
  108. package/src/components/toolbar/spam-assassin.tsx +40 -43
  109. package/src/components/toolbar/toolbar-button.tsx +52 -0
  110. package/src/components/toolbar/use-cached-state.ts +33 -0
  111. package/src/components/toolbar.tsx +196 -110
  112. package/src/components/tooltip-content.tsx +1 -1
  113. package/src/components/topbar/view-size-controls.tsx +1 -1
  114. package/src/components/topbar.tsx +4 -29
  115. package/src/contexts/emails.tsx +2 -1
  116. package/src/contexts/fragment-identifier.tsx +46 -0
  117. package/src/contexts/preview.tsx +81 -0
  118. package/src/hooks/use-email-rendering-result.ts +2 -1
  119. package/src/hooks/use-fragment-identifier.ts +14 -0
  120. package/src/utils/__snapshots__/get-email-component.spec.ts.snap +1 -1
  121. package/src/utils/caniemail/all-css-properties.ts +358 -0
  122. package/src/utils/caniemail/ast/get-object-variables.ts +61 -0
  123. package/src/utils/caniemail/ast/get-used-style-properties.ts +91 -0
  124. package/src/utils/caniemail/get-compatibility-stats-for-entry.ts +118 -0
  125. package/src/utils/caniemail/get-css-functions.ts +25 -0
  126. package/src/utils/caniemail/get-css-property-names.ts +32 -0
  127. package/src/utils/caniemail/get-css-property-with-value.ts +14 -0
  128. package/src/utils/caniemail/get-css-unit.ts +3 -0
  129. package/src/utils/caniemail/get-element-attributes.ts +7 -0
  130. package/src/utils/caniemail/get-element-names.ts +20 -0
  131. package/src/utils/caniemail/tailwind/generate-tailwind-rules.ts +30 -0
  132. package/src/utils/caniemail/tailwind/get-tailwind-config.ts +203 -0
  133. package/src/utils/caniemail/tailwind/get-tailwind-metadata.spec.ts +25 -0
  134. package/src/utils/caniemail/tailwind/get-tailwind-metadata.ts +45 -0
  135. package/src/utils/caniemail/tailwind/setup-tailwind-context.ts +15 -0
  136. package/src/utils/get-email-component.ts +34 -67
  137. package/src/utils/get-line-and-column-from-offset.spec.ts +11 -0
  138. package/src/utils/get-line-and-column-from-offset.ts +11 -0
  139. package/src/utils/index.ts +1 -0
  140. package/src/utils/linting.ts +60 -0
  141. package/src/utils/load-stream.ts +15 -0
  142. package/src/utils/result.ts +49 -0
  143. package/src/utils/run-bundled-code.ts +64 -0
  144. package/src/utils/sanitize.ts +6 -0
  145. package/tailwind-internals.d.ts +133 -0
  146. package/tsconfig.json +9 -3
  147. package/build-preview-server.mjs +0 -25
  148. package/dist/preview/.next/cache/images/TcyzHbFXGFjrOu3wEMvDoSmqCh3qP3iiNqJf0QbED9Y/60.1741728556140.cQ5qicbpvoXZ7leVmWqG2ElLwXB1ynYeSv8MBSA-QeM.Vy8iMWM3MGUtMTk1ODcxYmIyNzMi.webp +0 -0
  149. package/dist/preview/.next/cache/webpack/client-development/0.pack.gz +0 -0
  150. package/dist/preview/.next/cache/webpack/client-development/1.pack.gz +0 -0
  151. package/dist/preview/.next/cache/webpack/client-development/10.pack.gz +0 -0
  152. package/dist/preview/.next/cache/webpack/client-development/11.pack.gz +0 -0
  153. package/dist/preview/.next/cache/webpack/client-development/12.pack.gz +0 -0
  154. package/dist/preview/.next/cache/webpack/client-development/13.pack.gz +0 -0
  155. package/dist/preview/.next/cache/webpack/client-development/2.pack.gz +0 -0
  156. package/dist/preview/.next/cache/webpack/client-development/3.pack.gz +0 -0
  157. package/dist/preview/.next/cache/webpack/client-development/4.pack.gz +0 -0
  158. package/dist/preview/.next/cache/webpack/client-development/5.pack.gz +0 -0
  159. package/dist/preview/.next/cache/webpack/client-development/6.pack.gz +0 -0
  160. package/dist/preview/.next/cache/webpack/client-development/7.pack.gz +0 -0
  161. package/dist/preview/.next/cache/webpack/client-development/8.pack.gz +0 -0
  162. package/dist/preview/.next/cache/webpack/client-development/9.pack.gz +0 -0
  163. package/dist/preview/.next/cache/webpack/client-development/index.pack.gz +0 -0
  164. package/dist/preview/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  165. package/dist/preview/.next/cache/webpack/server-development/0.pack.gz +0 -0
  166. package/dist/preview/.next/cache/webpack/server-development/1.pack.gz +0 -0
  167. package/dist/preview/.next/cache/webpack/server-development/2.pack.gz +0 -0
  168. package/dist/preview/.next/cache/webpack/server-development/3.pack.gz +0 -0
  169. package/dist/preview/.next/cache/webpack/server-development/4.pack.gz +0 -0
  170. package/dist/preview/.next/cache/webpack/server-development/5.pack.gz +0 -0
  171. package/dist/preview/.next/cache/webpack/server-development/6.pack.gz +0 -0
  172. package/dist/preview/.next/cache/webpack/server-development/7.pack.gz +0 -0
  173. package/dist/preview/.next/cache/webpack/server-development/8.pack.gz +0 -0
  174. package/dist/preview/.next/cache/webpack/server-development/9.pack.gz +0 -0
  175. package/dist/preview/.next/cache/webpack/server-development/index.pack.gz +0 -0
  176. package/dist/preview/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  177. package/dist/preview/.next/server/chunks/143.js +0 -6
  178. package/dist/preview/.next/server/chunks/409.js +0 -5
  179. package/dist/preview/.next/server/chunks/46.js +0 -1
  180. package/dist/preview/.next/server/chunks/478.js +0 -14
  181. package/dist/preview/.next/server/chunks/707.js +0 -13
  182. package/dist/preview/.next/static/B4EYZiVzdylEG9lAIl-aO/_buildManifest.js +0 -1
  183. package/dist/preview/.next/static/chunks/575-bc52750855c25df4.js +0 -2
  184. package/dist/preview/.next/static/chunks/684-0f1ef7361c499798.js +0 -1
  185. package/dist/preview/.next/static/chunks/684c6b30-0c65da32762fc4ee.js +0 -1
  186. package/dist/preview/.next/static/chunks/81-e7539b08d9d3fb4d.js +0 -1
  187. package/dist/preview/.next/static/chunks/883-70c8267c50bc4133.js +0 -1
  188. package/dist/preview/.next/static/chunks/921-d1dc8c63f49e85d6.js +0 -1
  189. package/dist/preview/.next/static/chunks/app/_not-found/page-03ce767859c36d4e.js +0 -1
  190. package/dist/preview/.next/static/chunks/app/layout-7cf14e28880544f1.js +0 -1
  191. package/dist/preview/.next/static/chunks/app/page-065cb49b0a078541.js +0 -1
  192. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-656510fd180c803c.js +0 -1
  193. package/dist/preview/.next/static/chunks/framework-2a724981073c3a29.js +0 -1
  194. package/dist/preview/.next/static/chunks/main-552b9719bbc3a274.js +0 -1
  195. package/dist/preview/.next/static/chunks/main-app-914a73336fd45af5.js +0 -1
  196. package/dist/preview/.next/static/chunks/pages/_app-77ca34bce25ac75c.js +0 -1
  197. package/dist/preview/.next/static/chunks/pages/_error-73f611c46abbb495.js +0 -1
  198. package/dist/preview/.next/static/css/2df96d9ee014e8de.css +0 -3
  199. package/src/actions/email-validation/get-line-and-column-from-index.spec.ts +0 -22
  200. package/src/actions/email-validation/get-line-and-column-from-index.ts +0 -43
  201. package/src/components/icons/icon-scanner.tsx +0 -19
  202. package/src/components/icons/icon-scissors.tsx +0 -19
  203. /package/dist/preview/.next/static/{B4EYZiVzdylEG9lAIl-aO → Pms2orsQgT5xpttCfZfH5}/_ssgManifest.js +0 -0
@@ -0,0 +1,18 @@
1
+ import * as React from 'react';
2
+ import type { IconElement, IconProps } from './icon-base';
3
+ import { IconBase } from './icon-base';
4
+
5
+ export const IconInfo = React.forwardRef<IconElement, Readonly<IconProps>>(
6
+ ({ ...props }, forwardedRef) => (
7
+ <IconBase ref={forwardedRef} {...props}>
8
+ <path
9
+ d="M12 4C7.58173 4 4 7.58172 4 12C4 16.4182 7.58173 20 12 20C16.4183 20 20 16.4182 20 12C20 7.58172 16.4183 4 12 4ZM5.14754 12C5.14754 8.21549 8.21551 5.14754 12 5.14754C15.7845 5.14754 18.8525 8.21549 18.8525 12C18.8525 15.7844 15.7845 18.8525 12 18.8525C8.21551 18.8525 5.14754 15.7844 5.14754 12ZM12.906 8.37648C12.906 8.87682 12.5004 9.28243 12 9.28243C11.4997 9.28243 11.0941 8.87682 11.0941 8.37648C11.0941 7.87613 11.4997 7.47053 12 7.47053C12.5004 7.47053 12.906 7.87613 12.906 8.37648ZM10.1883 10.1884H10.7922H12.0002C12.3337 10.1884 12.6041 10.4588 12.6041 10.7924V15.0201H13.2081H13.8121V16.2281H13.2081H12.0002H10.7922H10.1883V15.0201H10.7922H11.3962V11.3963H10.7922H10.1883V10.1884Z"
10
+ fill="currentColor"
11
+ fillRule="evenodd"
12
+ clipRule="evenodd"
13
+ />
14
+ </IconBase>
15
+ ),
16
+ );
17
+
18
+ IconInfo.displayName = 'IconInfo';
@@ -1,19 +1,18 @@
1
- export const IconReload = (props: React.ComponentProps<'svg'>) => {
2
- return (
3
- <svg
4
- width="12"
5
- height="12"
6
- viewBox="0 0 12 12"
7
- fill="none"
8
- xmlns="http://www.w3.org/2000/svg"
9
- {...props}
10
- >
1
+ import * as React from 'react';
2
+ import type { IconElement, IconProps } from './icon-base';
3
+ import { IconBase } from './icon-base';
4
+
5
+ export const IconReload = React.forwardRef<IconElement, Readonly<IconProps>>(
6
+ ({ ...props }, forwardedRef) => (
7
+ <IconBase ref={forwardedRef} {...props}>
11
8
  <path
12
9
  fillRule="evenodd"
13
10
  clipRule="evenodd"
14
- d="M10.52 6C10.52 3.73168 8.75221 1.48 6.00006 1.48C3.77741 1.48 2.67886 3.1251 2.21074 3.99999H3.60005C3.82096 3.99999 4.00005 4.17908 4.00005 4.39999C4.00005 4.6209 3.82096 4.79999 3.60005 4.79999H1.20005C0.979137 4.79999 0.800049 4.6209 0.800049 4.39999V1.99999C0.800049 1.77908 0.979137 1.59999 1.20005 1.59999C1.42096 1.59999 1.60005 1.77908 1.60005 1.99999V3.45056C2.16367 2.45702 3.4673 0.679993 6.00006 0.679993C9.25029 0.679993 11.32 3.34831 11.32 6C11.32 8.65169 9.25029 11.32 6.00006 11.32C4.44499 11.32 3.15027 10.7047 2.22843 9.76673C1.73486 9.26449 1.34939 8.67121 1.08658 8.03257C1.0025 7.8283 1.09995 7.59453 1.30424 7.51046C1.50853 7.42638 1.7423 7.52384 1.82637 7.72812C2.05104 8.27401 2.38001 8.77961 2.79901 9.20593C3.57646 9.99705 4.66802 10.52 6.00006 10.52C8.75221 10.52 10.52 8.26833 10.52 6Z"
11
+ d="M17.9354 12C17.9354 9.01537 15.5828 6.05264 11.9202 6.05264C8.96229 6.05264 7.50033 8.21724 6.87735 9.36841H8.72625C9.02024 9.36841 9.25858 9.60406 9.25858 9.89473C9.25858 10.1854 9.02024 10.421 8.72625 10.421H5.53232C5.23833 10.421 5 10.1854 5 9.89473V6.73684C5 6.44617 5.23833 6.21052 5.53232 6.21052C5.82631 6.21052 6.06465 6.44617 6.06465 6.73684V8.64548C6.81471 7.33819 8.54959 5 11.9202 5C16.2456 5 19 8.51094 19 12C19 15.4891 16.2456 19 11.9202 19C9.8507 19 8.12769 18.1904 6.9009 16.9562C6.24405 16.2954 5.73107 15.5148 5.38132 14.6744C5.26942 14.4057 5.39911 14.0981 5.67098 13.9875C5.94285 13.8768 6.25395 14.0051 6.36583 14.2738C6.66482 14.9921 7.10262 15.6574 7.66023 16.2183C8.69486 17.2593 10.1475 17.9474 11.9202 17.9474C15.5828 17.9474 17.9354 14.9846 17.9354 12Z"
15
12
  fill="currentColor"
16
13
  />
17
- </svg>
18
- );
19
- };
14
+ </IconBase>
15
+ ),
16
+ );
17
+
18
+ IconReload.displayName = 'IconReload';
@@ -1,10 +1,11 @@
1
1
  export const Logo = () => (
2
2
  <svg
3
3
  fill="none"
4
- height="32"
4
+ height="30"
5
5
  viewBox="0 0 119 32"
6
- width="119"
6
+ width="113"
7
7
  xmlns="http://www.w3.org/2000/svg"
8
+ style={{ opacity: 0.9 }}
8
9
  >
9
10
  <g clipPath="url(#clip0_27_291)">
10
11
  <path
@@ -100,10 +100,7 @@ export const ResizableWarpper = ({
100
100
  return (
101
101
  <div
102
102
  {...rest}
103
- className={cn(
104
- 'relative mx-auto my-auto box-content px-4 py-2',
105
- rest.className,
106
- )}
103
+ className={cn('relative mx-auto my-auto box-content', rest.className)}
107
104
  >
108
105
  <div
109
106
  aria-label="resize-west"
@@ -71,6 +71,7 @@ export const FileTreeDirectoryChildren = (props: {
71
71
  pathname: `/preview/${emailSlug}`,
72
72
  search: searchParams.toString(),
73
73
  }}
74
+ prefetch
74
75
  key={emailSlug}
75
76
  >
76
77
  <motion.span
@@ -2,7 +2,6 @@
2
2
  import { clsx } from 'clsx';
3
3
  import { useEmails } from '../../contexts/emails';
4
4
  import { cn } from '../../utils';
5
- import { Heading } from '../heading';
6
5
  import { Logo } from '../logo';
7
6
  import { FileTree } from './file-tree';
8
7
 
@@ -29,9 +28,9 @@ export const Sidebar = ({ className, currentEmailOpenSlug }: SidebarProps) => {
29
28
  'hidden min-h-[3.3125rem] flex-shrink items-center p-3 px-4 lg:flex',
30
29
  )}
31
30
  >
32
- <Heading as="h2" className="truncate" size="2" weight="medium">
31
+ <h2>
33
32
  <Logo />
34
- </Heading>
33
+ </h2>
35
34
  </div>
36
35
  <div className="relative h-full w-full border-slate-4 border-t px-4 pb-3">
37
36
  <FileTree
@@ -0,0 +1,40 @@
1
+ import Link from 'next/link';
2
+ import { useSearchParams } from 'next/navigation';
3
+
4
+ interface CodePreviewLineLinkProps {
5
+ line: number;
6
+ column: number;
7
+
8
+ type: 'react' | 'html';
9
+ }
10
+
11
+ export const CodePreviewLineLink = ({
12
+ line,
13
+ column,
14
+ type,
15
+ }: CodePreviewLineLinkProps) => {
16
+ const searchParams = useSearchParams();
17
+
18
+ const newSearchParams = new URLSearchParams(searchParams);
19
+ newSearchParams.set('view', 'source');
20
+ if (type === 'html') {
21
+ newSearchParams.set('lang', 'markup');
22
+ } else if (type === 'react') {
23
+ newSearchParams.set('lang', 'jsx');
24
+ }
25
+
26
+ const fragmentIdentifier = `#L${line}`;
27
+
28
+ return (
29
+ <Link
30
+ href={{
31
+ search: newSearchParams.toString(),
32
+ hash: fragmentIdentifier,
33
+ }}
34
+ scroll={false}
35
+ className="appearance-none underline mx-2"
36
+ >
37
+ L{line.toString().padStart(2, '0')}
38
+ </Link>
39
+ );
40
+ };
@@ -0,0 +1,113 @@
1
+ import { useRef, useState } from 'react';
2
+ import { toast } from 'sonner';
3
+ import { nicenames } from '../../actions/email-validation/caniemail-data';
4
+ import {
5
+ type CompatibilityCheckingResult,
6
+ checkCompatibility,
7
+ } from '../../actions/email-validation/check-compatibility';
8
+ import { sanitize } from '../../utils';
9
+ import { loadStream } from '../../utils/load-stream';
10
+ import { IconWarning } from '../icons/icon-warning';
11
+ import { CodePreviewLineLink } from './code-preview-line-link';
12
+ import { Results } from './results';
13
+
14
+ export const useCompatibility = ({
15
+ reactMarkup,
16
+ emailPath,
17
+
18
+ initialResults,
19
+ }: {
20
+ reactMarkup: string;
21
+ emailPath: string;
22
+
23
+ initialResults?: CompatibilityCheckingResult[];
24
+ }) => {
25
+ const [results, setResults] = useState(initialResults);
26
+
27
+ const [loading, setLoading] = useState(false);
28
+ const isLoadingRef = useRef(false);
29
+
30
+ const load = async () => {
31
+ if (isLoadingRef.current) return;
32
+ isLoadingRef.current = true;
33
+ setLoading(true);
34
+
35
+ setResults([]);
36
+ let rawResults: CompatibilityCheckingResult[] = [];
37
+
38
+ try {
39
+ const stream = await checkCompatibility(reactMarkup, emailPath);
40
+ for await (const result of loadStream(stream)) {
41
+ if (result.status !== 'error') continue;
42
+ setResults((current) => {
43
+ if (!current) {
44
+ return [result];
45
+ }
46
+ rawResults = [...current, result];
47
+ return rawResults;
48
+ });
49
+ }
50
+ } catch (exception) {
51
+ console.error(exception);
52
+ toast.error(JSON.stringify(exception));
53
+ } finally {
54
+ setLoading(false);
55
+ isLoadingRef.current = false;
56
+ }
57
+
58
+ return rawResults;
59
+ };
60
+
61
+ return [results, { loading, load }] as const;
62
+ };
63
+
64
+ interface CompatibilityProps {
65
+ results: CompatibilityCheckingResult[] | undefined;
66
+ }
67
+
68
+ export const Compatibility = ({ results }: CompatibilityProps) => {
69
+ return (
70
+ <Results>
71
+ {results?.map((result, i) => {
72
+ const statsReportedNotWorking = Object.entries(
73
+ result.statsPerEmailClient,
74
+ ).filter(([, stats]) => stats.status === 'error');
75
+ const unsupportedClientsString = statsReportedNotWorking
76
+ .map(([emailClient]) => nicenames.family[emailClient])
77
+ .join(', ');
78
+
79
+ return (
80
+ <Results.Row key={i}>
81
+ <Results.Column>
82
+ <span className="flex text-red-400 uppercase gap-2 items-center">
83
+ <IconWarning />
84
+ {sanitize(result.entry.title)}
85
+ </span>
86
+ </Results.Column>
87
+ <Results.Column>
88
+ {statsReportedNotWorking.length > 0
89
+ ? `Not supported in ${unsupportedClientsString}`
90
+ : null}
91
+
92
+ <a
93
+ href={result.entry.url}
94
+ className="underline ml-2 decoration-slate-9 decoration-1 hover:decoration-slate-11 transition-colors hover:text-slate-12"
95
+ rel="noreferrer"
96
+ target="_blank"
97
+ >
98
+ More ↗
99
+ </a>
100
+ </Results.Column>
101
+ <Results.Column className="font-mono text-slate-11 text-right">
102
+ <CodePreviewLineLink
103
+ line={result.location.start.line}
104
+ column={result.location.start.column}
105
+ type="react"
106
+ />
107
+ </Results.Column>
108
+ </Results.Row>
109
+ );
110
+ })}
111
+ </Results>
112
+ );
113
+ };
@@ -1,167 +1,268 @@
1
1
  import prettyBytes from 'pretty-bytes';
2
- import { useEffect, useState } from 'react';
3
- import {
4
- type ImageCheck,
5
- type ImageCheckingResult,
6
- checkImages,
7
- } from '../../actions/email-validation/check-images';
8
- import {
9
- type LinkCheck,
10
- type LinkCheckingResult,
11
- checkLinks,
12
- } from '../../actions/email-validation/check-links';
2
+ import { Children, useRef, useState } from 'react';
3
+ import type { ImageCheckingResult } from '../../actions/email-validation/check-images';
4
+ import type { LinkCheckingResult } from '../../actions/email-validation/check-links';
5
+ import { cn, sanitize } from '../../utils';
6
+ import { getLintingSources, loadLintingRowsFrom } from '../../utils/linting';
13
7
  import { IconWarning } from '../icons/icon-warning';
8
+ import { CodePreviewLineLink } from './code-preview-line-link';
14
9
  import { Results } from './results';
15
10
 
16
- type LintingResult = Omit<
17
- ImageCheckingResult | LinkCheckingResult,
18
- 'checks'
19
- > & {
20
- checks: (LinkCheck | ImageCheck)[];
21
- };
11
+ export type LintingRow =
12
+ | {
13
+ source: 'image';
14
+ result: ImageCheckingResult;
15
+ }
16
+ | {
17
+ source: 'link';
18
+ result: LinkCheckingResult;
19
+ };
22
20
 
23
21
  interface LinterProps {
24
- results: LintingResult[] | undefined;
22
+ rows: LintingRow[] | undefined;
25
23
  }
26
24
 
27
25
  export const useLinter = ({
28
26
  markup,
29
- slug,
27
+
28
+ initialRows,
30
29
  }: {
31
30
  markup: string;
32
- slug: string;
31
+
32
+ initialRows?: LintingRow[];
33
33
  }) => {
34
- const cacheKey = `linter-${slug.replaceAll('/', '-')}`;
35
- const [results, setResults] = useState<LintingResult[] | undefined>();
36
-
37
- useEffect(() => {
38
- const cachedValue =
39
- 'localStorage' in global ? global.localStorage.getItem(cacheKey) : null;
40
- if (cachedValue) {
41
- setResults(JSON.parse(cachedValue));
42
- }
43
- }, [cacheKey]);
34
+ const [rows, setRows] = useState<LintingRow[] | undefined>(initialRows);
35
+
36
+ const sources = getLintingSources(
37
+ markup,
38
+ 'location' in global
39
+ ? `${global.location.protocol}//${global.location.host}`
40
+ : '',
41
+ );
44
42
 
45
43
  const [loading, setLoading] = useState(false);
44
+ const isStreaming = useRef(false);
46
45
 
47
46
  const load = async () => {
47
+ if (isStreaming.current) return;
48
+ isStreaming.current = true;
48
49
  setLoading(true);
49
- const imageResultsReader = (
50
- await checkImages(markup, `${location.protocol}//${location.host}`)
51
- ).getReader();
52
- const linkResultsReader = (await checkLinks(markup)).getReader();
53
- setResults([]);
54
50
 
51
+ setRows([]);
55
52
  try {
56
- while (true) {
57
- const { done: imageResultsReaderDone, value: imageLintingResult } =
58
- await imageResultsReader.read();
59
- if (imageLintingResult) {
60
- setResults((results) => [...results!, imageLintingResult]);
61
- }
62
- const { done: linkResultsReaderDone, value: linkLintingResult } =
63
- await linkResultsReader.read();
64
- if (linkLintingResult) {
65
- setResults((results) => [...results!, linkLintingResult]);
66
- }
67
- if (linkResultsReaderDone && imageResultsReaderDone) {
68
- break;
69
- }
53
+ let rows: LintingRow[] = [];
54
+ for await (const row of loadLintingRowsFrom(sources)) {
55
+ setRows((current) => {
56
+ if (!current) {
57
+ return [row];
58
+ }
59
+ const newArray = [...current, row];
60
+ newArray.sort((a, b) => {
61
+ if (a.result.status === 'error' && b.result.status === 'warning') {
62
+ return -1;
63
+ }
64
+
65
+ if (a.result.status === 'warning' && b.result.status === 'error') {
66
+ return 1;
67
+ }
68
+
69
+ return 0;
70
+ });
71
+ rows = newArray;
72
+ return newArray;
73
+ });
70
74
  }
75
+ return rows;
71
76
  } finally {
72
- linkResultsReader.releaseLock();
73
- imageResultsReader.releaseLock();
74
77
  setLoading(false);
78
+ isStreaming.current = false;
75
79
  }
76
80
  };
77
81
 
78
- useEffect(() => {
79
- load();
80
- }, []);
81
-
82
- return [results, { loading, load }] as const;
82
+ return [rows, { loading, load }] as const;
83
83
  };
84
84
 
85
- export const Linter = ({ results }: LinterProps) => {
86
- if (results === undefined) return null;
85
+ export const Linter = ({ rows }: LinterProps) => {
86
+ if (rows === undefined) return null;
87
87
 
88
88
  return (
89
89
  <Results>
90
- {results
91
- .filter((result) => result.status !== 'success')
92
- .map((result, i) => {
93
- const metadataString = result.checks
94
- .map((check) => {
95
- if (
96
- check.type === 'fetch_attempt' &&
97
- check.metadata.fetchStatusCode
98
- )
99
- return `${check.metadata?.fetchStatusCode}`;
100
- if (check.type === 'image_size' && check.metadata.byteCount)
101
- return `${prettyBytes(check.metadata.byteCount)}`;
102
- return null;
103
- })
104
- .filter(Boolean)
105
- .join(' - ');
106
- const failingCheck = result.checks.find(
90
+ {rows.map((row, i) => {
91
+ if (row.source === 'link') {
92
+ const failingCheck = row.result.checks.find(
107
93
  (check) => check.passed === false,
108
94
  )!;
95
+ return (
96
+ <Result status={row.result.status} key={i}>
97
+ <Result.Name>{sanitize(failingCheck.type)}</Result.Name>
98
+ <Result.Description>
99
+ {failingCheck.type === 'security'
100
+ ? 'Insecure URL, use HTTPS instead of HTTP'
101
+ : null}
102
+ {failingCheck.type === 'fetch_attempt' &&
103
+ failingCheck.metadata.fetchStatusCode &&
104
+ failingCheck.metadata.fetchStatusCode >= 300 &&
105
+ failingCheck.metadata.fetchStatusCode < 400 ? (
106
+ <>
107
+ <strong>{failingCheck.metadata.fetchStatusCode}</strong>:
108
+ There was a redirect, the content may have been moved
109
+ </>
110
+ ) : null}
111
+ {failingCheck.type === 'fetch_attempt' &&
112
+ failingCheck.metadata.fetchStatusCode &&
113
+ failingCheck.metadata.fetchStatusCode >= 400 ? (
114
+ <>
115
+ <strong>{failingCheck.metadata.fetchStatusCode}</strong>:
116
+ The link is broken
117
+ </>
118
+ ) : null}
119
+ {failingCheck.type === 'syntax'
120
+ ? 'The link is broken due to invalid syntax'
121
+ : null}
109
122
 
110
- const checkDescription = (() => {
111
- if (failingCheck.type === 'security') {
112
- return 'Insecure URL, use HTTPS instead of HTTP';
123
+ <span className="ml-2 text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
124
+ {row.result.link}
125
+ </span>
126
+ </Result.Description>
127
+ <Result.Metadata>
128
+ {[
129
+ <CodePreviewLineLink
130
+ line={row.result.codeLocation.line}
131
+ column={row.result.codeLocation.column}
132
+ type="html"
133
+ />,
134
+ ]}
135
+ </Result.Metadata>
136
+ </Result>
137
+ );
138
+ }
139
+
140
+ if (row.source === 'image') {
141
+ const failingCheck = row.result.checks.find(
142
+ (check) => check.passed === false,
143
+ )!;
144
+ const metadata: React.ReactNode[] = [];
145
+ for (const check of row.result.checks) {
146
+ if (check.type === 'image_size' && check.metadata.byteCount) {
147
+ metadata.push(prettyBytes(check.metadata.byteCount));
113
148
  }
114
- if (
115
- failingCheck.type === 'fetch_attempt' &&
116
- failingCheck.metadata.fetchStatusCode !== undefined
117
- ) {
118
- if (
149
+ }
150
+ metadata.push(
151
+ <CodePreviewLineLink
152
+ line={row.result.codeLocation.line}
153
+ column={row.result.codeLocation.column}
154
+ type="html"
155
+ />,
156
+ );
157
+ return (
158
+ <Result status={row.result.status} key={i}>
159
+ <Result.Name>{sanitize(failingCheck.type)}</Result.Name>
160
+ <Result.Description>
161
+ {failingCheck.type === 'security'
162
+ ? 'Insecure URL, use HTTPS instead of HTTP'
163
+ : null}
164
+ {failingCheck.type === 'fetch_attempt' &&
165
+ failingCheck.metadata.fetchStatusCode &&
119
166
  failingCheck.metadata.fetchStatusCode >= 300 &&
120
- failingCheck.metadata.fetchStatusCode < 400
121
- ) {
122
- return `There was a redirect (${failingCheck.metadata.fetchStatusCode}), the content may have been moved`;
123
- }
124
- return `The link is broken, returning ${failingCheck.metadata.fetchStatusCode}`;
125
- }
126
- if (failingCheck.type === 'accessibility') {
127
- return 'Missing alt text';
128
- }
129
- if (failingCheck.type === 'syntax') {
130
- return 'The link is broken due to invalid syntax';
131
- }
132
- if (
133
- failingCheck.type === 'image_size' &&
134
- failingCheck.metadata.byteCount
135
- ) {
136
- return `This image is too large (${prettyBytes(failingCheck.metadata.byteCount)}), keep it under 1mb`;
137
- }
138
- })();
167
+ failingCheck.metadata.fetchStatusCode < 400 ? (
168
+ <>
169
+ <strong>{failingCheck.metadata.fetchStatusCode}</strong>:
170
+ There was a redirect, the image may have been moved
171
+ </>
172
+ ) : null}
173
+ {failingCheck.type === 'fetch_attempt' &&
174
+ failingCheck.metadata.fetchStatusCode &&
175
+ failingCheck.metadata.fetchStatusCode >= 400 ? (
176
+ <>
177
+ <strong>{failingCheck.metadata.fetchStatusCode}</strong>:
178
+ The image is broken
179
+ </>
180
+ ) : null}
181
+ {failingCheck.type === 'syntax'
182
+ ? 'The image is broken due to an invalid source'
183
+ : null}
139
184
 
140
- return (
141
- <Results.Row key={i}>
142
- <Results.Column>
143
- <span
144
- className="flex uppercase gap-1 items-center data-[status=error]:text-red-400 data-[status=warning]:text-orange-300"
145
- data-status={result.status}
146
- >
147
- <IconWarning />
148
- {failingCheck.type}
185
+ {failingCheck.type === 'accessibility'
186
+ ? 'Missing alt text'
187
+ : null}
188
+
189
+ {failingCheck.type === 'image_size' &&
190
+ failingCheck.metadata.byteCount
191
+ ? 'This image is too large, keep it under 1mb'
192
+ : null}
193
+
194
+ <span className="ml-2 text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
195
+ {row.result.source}
149
196
  </span>
150
- </Results.Column>
151
- <Results.Column>
152
- {checkDescription}
153
- {result.intendedFor && (
154
- <span className="float-right font-mono text-slate-11 max-w-[30ch] text-nowrap overflow-hidden text-ellipsis">
155
- {result.intendedFor}
156
- </span>
157
- )}
158
- </Results.Column>
159
- <Results.Column align="right" className="font-mono text-slate-11">
160
- {metadataString}
161
- </Results.Column>
162
- </Results.Row>
197
+ </Result.Description>
198
+ <Result.Metadata>{metadata}</Result.Metadata>
199
+ </Result>
163
200
  );
164
- })}
201
+ }
202
+
203
+ return undefined;
204
+ })}
165
205
  </Results>
166
206
  );
167
207
  };
208
+
209
+ interface ResultProps extends React.ComponentProps<typeof Results.Row> {
210
+ status: 'error' | 'warning' | 'success';
211
+ }
212
+
213
+ const Result = ({ children, className, status, ...props }: ResultProps) => {
214
+ return (
215
+ <Results.Row
216
+ data-status={status}
217
+ {...props}
218
+ className={cn('group/result', className)}
219
+ >
220
+ {children}
221
+ </Results.Row>
222
+ );
223
+ };
224
+
225
+ Result.Name = ({
226
+ children,
227
+ ...props
228
+ }: React.ComponentProps<typeof Results.Column>) => {
229
+ return (
230
+ <Results.Column {...props}>
231
+ <span className="flex uppercase gap-2 items-center group-data-[status=error]/result:text-red-400 group-data-[status=warning]/result:text-orange-300">
232
+ <IconWarning />
233
+ {typeof children === 'string' ? sanitize(children) : children}
234
+ </span>
235
+ </Results.Column>
236
+ );
237
+ };
238
+
239
+ Result.Description = ({
240
+ children,
241
+ className,
242
+ ...props
243
+ }: React.ComponentProps<typeof Results.Column>) => {
244
+ return <Results.Column {...props}>{children}</Results.Column>;
245
+ };
246
+
247
+ interface MetadatProps extends React.ComponentProps<typeof Results.Column> {
248
+ children: React.ReactNode[];
249
+ }
250
+
251
+ Result.Metadata = ({ children, className, ...props }: MetadatProps) => {
252
+ return (
253
+ <Results.Column
254
+ align="right"
255
+ {...props}
256
+ className={cn('font-mono text-slate-11', className)}
257
+ >
258
+ {Children.map(children, (child, index) => {
259
+ return (
260
+ <>
261
+ {index > 0 ? ' · ' : null}
262
+ {child}
263
+ </>
264
+ );
265
+ })}
266
+ </Results.Column>
267
+ );
268
+ };