create-nextblock 0.15.10 → 0.16.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.
Files changed (266) hide show
  1. package/docker-template/.dockerignore +1 -1
  2. package/libs/db/tsconfig.lib.json +3 -3
  3. package/libs/editor/tsconfig.lib.json +3 -3
  4. package/libs/ui/tsconfig.lib.json +3 -3
  5. package/libs/utils/tsconfig.json +3 -3
  6. package/package.json +50 -50
  7. package/project.json +19 -19
  8. package/templates/nextblock-template/.dockerignore +1 -1
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/AGENTS.md +9 -9
  11. package/templates/nextblock-template/CLAUDE.md +1 -1
  12. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  13. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  14. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  15. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
  16. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  17. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
  18. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
  19. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
  20. package/templates/nextblock-template/app/actions/email.ts +241 -241
  21. package/templates/nextblock-template/app/actions/formActions.ts +245 -245
  22. package/templates/nextblock-template/app/actions/interactions.ts +489 -489
  23. package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
  24. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
  25. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -0
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -2900
  30. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  31. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  32. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  33. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  34. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  35. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  37. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  38. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  39. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  40. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -552
  41. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
  42. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  43. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  44. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  45. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +180 -3
  46. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
  47. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  48. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  49. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  50. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +12 -3
  51. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  52. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  53. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  54. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  55. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  56. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  58. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  59. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  60. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  61. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  63. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  64. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  65. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
  66. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  67. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  68. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  69. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  70. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  71. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  72. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  73. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
  74. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +248 -5
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +240 -5
  77. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  78. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  79. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  80. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  81. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  82. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  83. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  84. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  85. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  86. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  90. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  91. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  92. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  93. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  96. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  98. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  99. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  100. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  101. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  102. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  103. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
  104. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
  105. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
  106. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
  107. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
  108. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
  109. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
  110. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  111. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  112. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  113. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  114. package/templates/nextblock-template/app/layout.tsx +671 -671
  115. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  116. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  117. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  118. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  119. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  120. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  121. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  122. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  123. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  124. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  125. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  126. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  127. package/templates/nextblock-template/app/providers.tsx +96 -96
  128. package/templates/nextblock-template/app/robots.ts +123 -0
  129. package/templates/nextblock-template/app/sitemap.ts +1 -1
  130. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  131. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  132. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  133. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  134. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  135. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  136. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  137. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  138. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  139. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  140. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  141. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  142. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  143. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  144. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  145. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  146. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  147. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  148. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  149. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  150. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  151. package/templates/nextblock-template/components/Header.tsx +38 -38
  152. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  153. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  154. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  155. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  156. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  157. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  158. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  159. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  160. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  161. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  162. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  163. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  164. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  165. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  166. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  167. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  168. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  169. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  170. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  171. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  172. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  173. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  174. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  175. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  176. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  177. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  178. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  179. package/templates/nextblock-template/components/form-message.tsx +32 -32
  180. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
  187. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  188. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  189. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  190. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  191. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  192. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  193. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  194. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  195. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  196. package/templates/nextblock-template/docs/README.md +42 -42
  197. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  198. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  199. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  200. package/templates/nextblock-template/index.d.ts +7 -7
  201. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  202. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  203. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  204. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  205. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  206. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  207. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  208. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  209. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  210. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  211. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  212. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  213. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
  215. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  216. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  217. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  218. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  219. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  220. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  221. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  222. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  223. package/templates/nextblock-template/lib/search/types.ts +27 -27
  224. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
  231. package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -167
  239. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  240. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  241. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  242. package/templates/nextblock-template/lib/site-url.ts +102 -102
  243. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  244. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  245. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  246. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  247. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  248. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  249. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  250. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  251. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  252. package/templates/nextblock-template/next-env.d.ts +0 -1
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/proxy.ts +240 -20
  256. package/templates/nextblock-template/scripts/backup.js +115 -115
  257. package/templates/nextblock-template/scripts/restore.js +385 -385
  258. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  259. package/templates/nextblock-template/tailwind.config.js +25 -25
  260. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  261. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  262. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  263. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  264. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  265. package/tsconfig.base.json +3 -3
  266. package/templates/nextblock-template/app/robots.txt/route.ts +0 -32
@@ -1,57 +1,57 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { reconcileTlsForPort } from './email-settings';
4
-
5
- /**
6
- * The TLS mode is a property of the SMTP port, not a preference — and getting it wrong
7
- * produces "ssl3_get_record:wrong version number", which mentions neither TLS nor ports.
8
- * A real install hit exactly that: SMTP2GO on 2525 with the toggle left at its default
9
- * of ON. These tests pin the reconciliation that makes the combination unreachable.
10
- */
11
-
12
- describe('reconcileTlsForPort', () => {
13
- it('forces implicit TLS on 465 even when the setting says otherwise', () => {
14
- const result = reconcileTlsForPort(465, false, 'smtp.example.com');
15
- expect(result.secure).toBe(true);
16
- expect(result.requireTLS).toBe(false);
17
- expect(result.correctedFrom).toBe(false);
18
- });
19
-
20
- it('leaves a correct 465 configuration untouched', () => {
21
- const result = reconcileTlsForPort(465, true, 'smtp.example.com');
22
- expect(result.secure).toBe(true);
23
- expect(result.correctedFrom).toBeUndefined();
24
- });
25
-
26
- it.each([25, 587, 2525])('forces STARTTLS on port %i', (port) => {
27
- const result = reconcileTlsForPort(port, true, 'mail.smtp2go.com');
28
- expect(result.secure).toBe(false);
29
- // Downgrading to plaintext would be worse than the bug being fixed.
30
- expect(result.requireTLS).toBe(true);
31
- expect(result.correctedFrom).toBe(true);
32
- });
33
-
34
- it('reproduces the reported failure: SMTP2GO on 2525 with the toggle left on', () => {
35
- const result = reconcileTlsForPort(2525, true, 'mail.smtp2go.com');
36
- expect(result.secure).toBe(false);
37
- expect(result.correctedFrom).toBe(true);
38
- });
39
-
40
- it('does not demand STARTTLS from a local test relay', () => {
41
- for (const host of ['localhost', '127.0.0.1', '::1', 'LocalHost']) {
42
- expect(reconcileTlsForPort(587, false, host).requireTLS).toBe(false);
43
- }
44
- });
45
-
46
- it('honours the operator on a non-standard port, either way', () => {
47
- expect(reconcileTlsForPort(1025, false, 'localhost')).toMatchObject({
48
- secure: false,
49
- requireTLS: false,
50
- });
51
- expect(reconcileTlsForPort(2526, true, 'smtp.example.com')).toMatchObject({
52
- secure: true,
53
- requireTLS: false,
54
- });
55
- expect(reconcileTlsForPort(2526, true, 'smtp.example.com').correctedFrom).toBeUndefined();
56
- });
57
- });
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { reconcileTlsForPort } from './email-settings';
4
+
5
+ /**
6
+ * The TLS mode is a property of the SMTP port, not a preference — and getting it wrong
7
+ * produces "ssl3_get_record:wrong version number", which mentions neither TLS nor ports.
8
+ * A real install hit exactly that: SMTP2GO on 2525 with the toggle left at its default
9
+ * of ON. These tests pin the reconciliation that makes the combination unreachable.
10
+ */
11
+
12
+ describe('reconcileTlsForPort', () => {
13
+ it('forces implicit TLS on 465 even when the setting says otherwise', () => {
14
+ const result = reconcileTlsForPort(465, false, 'smtp.example.com');
15
+ expect(result.secure).toBe(true);
16
+ expect(result.requireTLS).toBe(false);
17
+ expect(result.correctedFrom).toBe(false);
18
+ });
19
+
20
+ it('leaves a correct 465 configuration untouched', () => {
21
+ const result = reconcileTlsForPort(465, true, 'smtp.example.com');
22
+ expect(result.secure).toBe(true);
23
+ expect(result.correctedFrom).toBeUndefined();
24
+ });
25
+
26
+ it.each([25, 587, 2525])('forces STARTTLS on port %i', (port) => {
27
+ const result = reconcileTlsForPort(port, true, 'mail.smtp2go.com');
28
+ expect(result.secure).toBe(false);
29
+ // Downgrading to plaintext would be worse than the bug being fixed.
30
+ expect(result.requireTLS).toBe(true);
31
+ expect(result.correctedFrom).toBe(true);
32
+ });
33
+
34
+ it('reproduces the reported failure: SMTP2GO on 2525 with the toggle left on', () => {
35
+ const result = reconcileTlsForPort(2525, true, 'mail.smtp2go.com');
36
+ expect(result.secure).toBe(false);
37
+ expect(result.correctedFrom).toBe(true);
38
+ });
39
+
40
+ it('does not demand STARTTLS from a local test relay', () => {
41
+ for (const host of ['localhost', '127.0.0.1', '::1', 'LocalHost']) {
42
+ expect(reconcileTlsForPort(587, false, host).requireTLS).toBe(false);
43
+ }
44
+ });
45
+
46
+ it('honours the operator on a non-standard port, either way', () => {
47
+ expect(reconcileTlsForPort(1025, false, 'localhost')).toMatchObject({
48
+ secure: false,
49
+ requireTLS: false,
50
+ });
51
+ expect(reconcileTlsForPort(2526, true, 'smtp.example.com')).toMatchObject({
52
+ secure: true,
53
+ requireTLS: false,
54
+ });
55
+ expect(reconcileTlsForPort(2526, true, 'smtp.example.com').correctedFrom).toBeUndefined();
56
+ });
57
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Helpers for assembling a valid `POST /api/ai/seo/alt-text` body from what the CMS has
3
+ * on hand. Both call sites — the image block editor and the media library edit form —
4
+ * face the same two problems, so they are solved once here.
5
+ */
6
+
7
+ /**
8
+ * Turn whatever the CMS has for an image into a URL the AI vision call can actually fetch.
9
+ *
10
+ * Why this is not just "pass the src along": `/api/ai/seo/alt-text` does not receive the
11
+ * image bytes, it receives a URL, and the AI SDK downloads that URL *server-side* before
12
+ * handing the image to the model. A relative path is meaningless in that context — there
13
+ * is no document base to resolve it against — so the route rejects anything that is not
14
+ * absolute http(s).
15
+ *
16
+ * And relative is the common case, not the exotic one. `resolveMediaUrl()` returns
17
+ * `/${objectKey}` whenever `NEXT_PUBLIC_R2_BASE_URL` is unset, which is exactly how the
18
+ * native Supabase-storage backend runs. So on a default install every single image in
19
+ * the library would otherwise fail the alt-text call. Resolving against
20
+ * `window.location.origin` fixes that for any deployment the browser can reach, which is
21
+ * the same origin the server is serving from.
22
+ *
23
+ * The remaining failure the caller must surface rather than swallow is a `blob:` or
24
+ * `data:` src (an un-uploaded local preview): the server cannot fetch either, so we
25
+ * return null and let the UI say so instead of firing a request that is certain to 400.
26
+ */
27
+ export function toAbsoluteImageUrl(url: string | null | undefined): string | null {
28
+ const candidate = (url ?? '').trim();
29
+ if (!candidate) {
30
+ return null;
31
+ }
32
+
33
+ if (/^https?:\/\//i.test(candidate)) {
34
+ return candidate;
35
+ }
36
+
37
+ // Only the browser knows the origin this CMS is being served from; on the server we
38
+ // have no basis to invent one, so a relative path stays unusable and the caller warns.
39
+ if (typeof window === 'undefined') {
40
+ return null;
41
+ }
42
+
43
+ try {
44
+ const absolute = new URL(candidate, window.location.origin);
45
+ return absolute.protocol === 'http:' || absolute.protocol === 'https:'
46
+ ? absolute.toString()
47
+ : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** Shared copy for the inline warning shown when {@link toAbsoluteImageUrl} returns null. */
54
+ export const UNRESOLVABLE_IMAGE_URL_MESSAGE =
55
+ 'This image has no public http(s) address yet, so Cortex AI cannot look at it. Save it to the media library first.';
56
+
57
+ /**
58
+ * The route validates `context` with `z.string().max(2000)` inside a `z.strictObject`, so
59
+ * an over-long value does not get trimmed server-side — it fails the whole request with a
60
+ * 400. Captions are free-text inputs with no length limit of their own, which makes that
61
+ * reachable: a long caption would break the alt-text button for reasons the operator could
62
+ * never guess from the error. Trimming client-side keeps the request valid, and losing the
63
+ * tail of a 2,000-character caption costs nothing, since context is a hint and the model
64
+ * has the image itself.
65
+ */
66
+ const ALT_TEXT_CONTEXT_MAX_LENGTH = 2000;
67
+
68
+ /**
69
+ * Join the context fragments a caller has into one string the route will accept, or return
70
+ * `undefined` when there is nothing worth sending — the field is optional, and an empty
71
+ * string is a different (and less honest) thing to send than no field at all.
72
+ */
73
+ export function buildAltTextContext(...fragments: Array<string | null | undefined>): string | undefined {
74
+ const joined = fragments
75
+ .map((fragment) => (fragment ?? '').trim())
76
+ .filter(Boolean)
77
+ .join(' ');
78
+
79
+ if (!joined) {
80
+ return undefined;
81
+ }
82
+
83
+ return joined.length > ALT_TEXT_CONTEXT_MAX_LENGTH
84
+ ? joined.slice(0, ALT_TEXT_CONTEXT_MAX_LENGTH)
85
+ : joined;
86
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Sandbox credential passthrough for browser-initiated Cortex AI requests.
3
+ *
4
+ * On the hosted sandbox there is no server-side OpenRouter key, because every visitor
5
+ * shares one deployment and we will not spend the project's credits on strangers.
6
+ * Instead each visitor pastes their own key into the Cortex AI settings screen, which
7
+ * stores it in `localStorage`, and every AI call made from the browser forwards it on
8
+ * the request as a header. The route only reads those headers when
9
+ * `NEXT_PUBLIC_IS_SANDBOX === 'true'`, so on a real install the headers are ignored
10
+ * even if something were to send them.
11
+ *
12
+ * This lived inline in `NotionEditor.handleAiGenerate` and was about to be copied into
13
+ * three more call sites (image alt text, media alt text, page/post metadata). Copies
14
+ * drift: the day a storage key or header name changes, the forgotten copy silently
15
+ * stops authenticating and the sandbox user sees "no credentials" from one button and
16
+ * not another. One exported builder keeps that impossible.
17
+ */
18
+
19
+ /** localStorage key holding the visitor's own OpenRouter API key on the sandbox. */
20
+ export const CORTEX_AI_SANDBOX_API_KEY_STORAGE_KEY = 'cortex_ai_sandbox_openrouter_api_key';
21
+
22
+ /** localStorage key holding the visitor's serialized model selection on the sandbox. */
23
+ export const CORTEX_AI_SANDBOX_MODEL_STORAGE_KEY = 'cortex_ai_sandbox_openrouter_model_selection';
24
+
25
+ /**
26
+ * Builds the headers for a JSON POST to a Cortex AI route, adding the sandbox
27
+ * credential headers when — and only when — this build is the sandbox and the visitor
28
+ * has actually stored a key.
29
+ *
30
+ * `localStorage` access is wrapped in try/catch on purpose: a browser configured to
31
+ * block site data throws on the *accessor itself*, not just on read, and an AI button
32
+ * that explodes before it can send a request is a far worse failure than one that sends
33
+ * an unauthenticated request and gets a clean 403 back.
34
+ */
35
+ export function buildCortexAiRequestHeaders(): Record<string, string> {
36
+ const headers: Record<string, string> = {
37
+ 'content-type': 'application/json',
38
+ };
39
+
40
+ if (process.env.NEXT_PUBLIC_IS_SANDBOX !== 'true' || typeof window === 'undefined') {
41
+ return headers;
42
+ }
43
+
44
+ try {
45
+ const sandboxKey = window.localStorage.getItem(CORTEX_AI_SANDBOX_API_KEY_STORAGE_KEY);
46
+ const sandboxModel = window.localStorage.getItem(CORTEX_AI_SANDBOX_MODEL_STORAGE_KEY);
47
+
48
+ if (sandboxKey) {
49
+ headers['x-sandbox-openrouter-key'] = sandboxKey;
50
+ }
51
+ if (sandboxModel) {
52
+ headers['x-sandbox-openrouter-model'] = sandboxModel;
53
+ }
54
+ } catch {
55
+ // Storage is unavailable (private window, blocked site data). Fall through with the
56
+ // plain content-type header; the route will answer 403 and the caller will toast it.
57
+ }
58
+
59
+ return headers;
60
+ }
@@ -1,59 +1,59 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { isPlaceholderEmail, usableEmail } from './placeholder-address';
4
-
5
- /**
6
- * The starter content ships a contact page addressed to `contact@example.com`, and an
7
- * install ran that way without knowing: the visitor was thanked, the relay accepted the
8
- * message, and it went to a domain RFC 2606 reserves so it can never exist. No error
9
- * anywhere. Treating these as unset is what lets the recipient ladder reach something
10
- * real — and lets the CMS say so.
11
- */
12
-
13
- describe('isPlaceholderEmail', () => {
14
- it.each([
15
- 'contact@example.com',
16
- 'CONTACT@EXAMPLE.COM',
17
- ' hello@example.com ',
18
- 'privacy@example.org',
19
- 'support@example.net',
20
- 'someone@example.edu',
21
- 'a@host.example',
22
- 'a@something.invalid',
23
- 'a@my.test',
24
- 'a@dev.localhost',
25
- 'a@printer.local',
26
- ])('treats %s as a placeholder', (email) => {
27
- expect(isPlaceholderEmail(email)).toBe(true);
28
- expect(usableEmail(email)).toBeNull();
29
- });
30
-
31
- it.each([
32
- 'hosts@newrootsherbal.com',
33
- 'sales@nextblock.dev',
34
- // A real domain that merely contains the word, and a real TLD lookalike.
35
- 'hi@example-company.com',
36
- 'hi@myexample.com',
37
- 'hi@shop.exampleshop.co.uk',
38
- ])('leaves the real address %s alone', (email) => {
39
- expect(isPlaceholderEmail(email)).toBe(false);
40
- expect(usableEmail(email)).toBe(email);
41
- });
42
-
43
- it('does not treat an empty or malformed value as a placeholder', () => {
44
- // Empty means "unset", which callers already handle; malformed is not our call.
45
- for (const value of ['', ' ', null, undefined, 'not-an-email']) {
46
- expect(isPlaceholderEmail(value)).toBe(false);
47
- }
48
- });
49
-
50
- it('reports an empty value as unusable without calling it a placeholder', () => {
51
- expect(usableEmail('')).toBeNull();
52
- expect(usableEmail(null)).toBeNull();
53
- expect(usableEmail(' ')).toBeNull();
54
- });
55
-
56
- it('trims a usable address rather than returning it padded', () => {
57
- expect(usableEmail(' sales@nextblock.dev ')).toBe('sales@nextblock.dev');
58
- });
59
- });
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { isPlaceholderEmail, usableEmail } from './placeholder-address';
4
+
5
+ /**
6
+ * The starter content ships a contact page addressed to `contact@example.com`, and an
7
+ * install ran that way without knowing: the visitor was thanked, the relay accepted the
8
+ * message, and it went to a domain RFC 2606 reserves so it can never exist. No error
9
+ * anywhere. Treating these as unset is what lets the recipient ladder reach something
10
+ * real — and lets the CMS say so.
11
+ */
12
+
13
+ describe('isPlaceholderEmail', () => {
14
+ it.each([
15
+ 'contact@example.com',
16
+ 'CONTACT@EXAMPLE.COM',
17
+ ' hello@example.com ',
18
+ 'privacy@example.org',
19
+ 'support@example.net',
20
+ 'someone@example.edu',
21
+ 'a@host.example',
22
+ 'a@something.invalid',
23
+ 'a@my.test',
24
+ 'a@dev.localhost',
25
+ 'a@printer.local',
26
+ ])('treats %s as a placeholder', (email) => {
27
+ expect(isPlaceholderEmail(email)).toBe(true);
28
+ expect(usableEmail(email)).toBeNull();
29
+ });
30
+
31
+ it.each([
32
+ 'hosts@newrootsherbal.com',
33
+ 'sales@nextblock.dev',
34
+ // A real domain that merely contains the word, and a real TLD lookalike.
35
+ 'hi@example-company.com',
36
+ 'hi@myexample.com',
37
+ 'hi@shop.exampleshop.co.uk',
38
+ ])('leaves the real address %s alone', (email) => {
39
+ expect(isPlaceholderEmail(email)).toBe(false);
40
+ expect(usableEmail(email)).toBe(email);
41
+ });
42
+
43
+ it('does not treat an empty or malformed value as a placeholder', () => {
44
+ // Empty means "unset", which callers already handle; malformed is not our call.
45
+ for (const value of ['', ' ', null, undefined, 'not-an-email']) {
46
+ expect(isPlaceholderEmail(value)).toBe(false);
47
+ }
48
+ });
49
+
50
+ it('reports an empty value as unusable without calling it a placeholder', () => {
51
+ expect(usableEmail('')).toBeNull();
52
+ expect(usableEmail(null)).toBeNull();
53
+ expect(usableEmail(' ')).toBeNull();
54
+ });
55
+
56
+ it('trims a usable address rather than returning it padded', () => {
57
+ expect(usableEmail(' sales@nextblock.dev ')).toBe('sales@nextblock.dev');
58
+ });
59
+ });
@@ -1,39 +1,39 @@
1
- /**
2
- * Addresses that look configured but can never receive mail.
3
- *
4
- * Seed data has to put *something* in a contact field, and `contact@example.com` is the
5
- * conventional choice. The trouble is that it is indistinguishable from a real setting to
6
- * every code path downstream: the form reports success, the transport accepts the
7
- * message, and it is delivered nowhere. An operator can run that way for months without
8
- * a single error to notice.
9
- *
10
- * The domains here are reserved by RFC 2606 and RFC 6761 precisely so they can never be
11
- * registered, which is what makes this a safe judgement rather than a guess: nothing at
12
- * `example.com` is ever a real inbox. Treating them as unset lets the recipient ladder
13
- * fall through to something that works, and lets the CMS say so out loud.
14
- *
15
- * Deliberately dependency-free so both server modules and the CMS can use it.
16
- */
17
-
18
- const RESERVED_DOMAINS = new Set(['example.com', 'example.org', 'example.net', 'example.edu']);
19
- const RESERVED_TLDS = ['.example', '.invalid', '.test', '.localhost', '.local'];
20
-
21
- export function isPlaceholderEmail(value: string | null | undefined): boolean {
22
- const email = value?.trim().toLowerCase() ?? '';
23
- if (!email) return false;
24
-
25
- const at = email.lastIndexOf('@');
26
- if (at === -1) return false;
27
-
28
- const domain = email.slice(at + 1);
29
- if (!domain) return false;
30
-
31
- return RESERVED_DOMAINS.has(domain) || RESERVED_TLDS.some((tld) => domain.endsWith(tld));
32
- }
33
-
34
- /** The address if it could actually receive mail, otherwise null. */
35
- export function usableEmail(value: string | null | undefined): string | null {
36
- const email = value?.trim() ?? '';
37
- if (!email || isPlaceholderEmail(email)) return null;
38
- return email;
39
- }
1
+ /**
2
+ * Addresses that look configured but can never receive mail.
3
+ *
4
+ * Seed data has to put *something* in a contact field, and `contact@example.com` is the
5
+ * conventional choice. The trouble is that it is indistinguishable from a real setting to
6
+ * every code path downstream: the form reports success, the transport accepts the
7
+ * message, and it is delivered nowhere. An operator can run that way for months without
8
+ * a single error to notice.
9
+ *
10
+ * The domains here are reserved by RFC 2606 and RFC 6761 precisely so they can never be
11
+ * registered, which is what makes this a safe judgement rather than a guess: nothing at
12
+ * `example.com` is ever a real inbox. Treating them as unset lets the recipient ladder
13
+ * fall through to something that works, and lets the CMS say so out loud.
14
+ *
15
+ * Deliberately dependency-free so both server modules and the CMS can use it.
16
+ */
17
+
18
+ const RESERVED_DOMAINS = new Set(['example.com', 'example.org', 'example.net', 'example.edu']);
19
+ const RESERVED_TLDS = ['.example', '.invalid', '.test', '.localhost', '.local'];
20
+
21
+ export function isPlaceholderEmail(value: string | null | undefined): boolean {
22
+ const email = value?.trim().toLowerCase() ?? '';
23
+ if (!email) return false;
24
+
25
+ const at = email.lastIndexOf('@');
26
+ if (at === -1) return false;
27
+
28
+ const domain = email.slice(at + 1);
29
+ if (!domain) return false;
30
+
31
+ return RESERVED_DOMAINS.has(domain) || RESERVED_TLDS.some((tld) => domain.endsWith(tld));
32
+ }
33
+
34
+ /** The address if it could actually receive mail, otherwise null. */
35
+ export function usableEmail(value: string | null | undefined): string | null {
36
+ const email = value?.trim() ?? '';
37
+ if (!email || isPlaceholderEmail(email)) return null;
38
+ return email;
39
+ }
@@ -1,70 +1,70 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { threadReference } from './threads';
4
-
5
- /**
6
- * A real install stopped receiving replies and nothing reported a fault: SMTP accepted
7
- * every message, Outlook accepted every message, and they went straight to Deleted Items.
8
- * The cause was the subject. Every conversation from one form produced a byte-identical
9
- * "Re: Contact form"; Exchange derives its ConversationTopic from the subject rather than
10
- * from References, so an Ignore Conversation applied once applied to every enquiry the
11
- * site would ever receive.
12
- *
13
- * A per-conversation reference is what stops one filtering decision swallowing the lot.
14
- */
15
-
16
- const THREAD_A = 'f4c00aff-a458-40cb-8f6b-7ec66e98779c';
17
- const THREAD_B = '34b62e11-8209-4beb-98e9-10b364bd2807';
18
-
19
- describe('threadReference', () => {
20
- it('is stable for a given thread', () => {
21
- expect(threadReference(THREAD_A, 'New Roots Herbal')).toBe(
22
- threadReference(THREAD_A, 'New Roots Herbal')
23
- );
24
- });
25
-
26
- it('differs between threads, which is the entire point', () => {
27
- expect(threadReference(THREAD_A, 'New Roots Herbal')).not.toBe(
28
- threadReference(THREAD_B, 'New Roots Herbal')
29
- );
30
- });
31
-
32
- it('reads as a ticket number rather than a hex dump', () => {
33
- expect(threadReference(THREAD_A, 'New Roots Herbal')).toBe('NRH-VWQOLB');
34
- expect(threadReference(THREAD_A, 'New Roots Herbal')).toMatch(/^[A-Z]{2,4}-[0-9A-Z]{6}$/);
35
- });
36
-
37
- it('builds initials from a multi-word site name', () => {
38
- expect(threadReference(THREAD_A, 'New Roots Herbal')).toMatch(/^NRH-/);
39
- expect(threadReference(THREAD_A, 'The Corner Shop')).toMatch(/^TCS-/);
40
- });
41
-
42
- it('falls back to the first three letters of a one-word name', () => {
43
- expect(threadReference(THREAD_A, 'NextBlock')).toMatch(/^NEX-/);
44
- });
45
-
46
- it('drops the prefix entirely when there is no usable site name', () => {
47
- expect(threadReference(THREAD_A, '')).toBe('VWQOLB');
48
- expect(threadReference(THREAD_A, ' ')).toBe('VWQOLB');
49
- expect(threadReference(THREAD_A)).toBe('VWQOLB');
50
- });
51
-
52
- it('ignores punctuation in the site name rather than emitting it', () => {
53
- expect(threadReference(THREAD_A, "Bob's Bikes & Co.")).toMatch(/^BBC-/);
54
- });
55
-
56
- it('survives a malformed id instead of throwing', () => {
57
- expect(threadReference('', 'New Roots Herbal')).toBe('NRH-000000');
58
- expect(threadReference('zzzzzzzz', 'New Roots Herbal')).toBe('NRH-000000');
59
- });
60
-
61
- it('keeps distinct conversations apart across a large sample', () => {
62
- const seen = new Set<string>();
63
- for (let i = 0; i < 2000; i += 1) {
64
- seen.add(threadReference(crypto.randomUUID(), 'New Roots Herbal'));
65
- }
66
- // 2.2 billion references; a handful of collisions in 2000 draws would signal a bug
67
- // in the fold, not birthday-paradox noise.
68
- expect(seen.size).toBe(2000);
69
- });
70
- });
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { threadReference } from './threads';
4
+
5
+ /**
6
+ * A real install stopped receiving replies and nothing reported a fault: SMTP accepted
7
+ * every message, Outlook accepted every message, and they went straight to Deleted Items.
8
+ * The cause was the subject. Every conversation from one form produced a byte-identical
9
+ * "Re: Contact form"; Exchange derives its ConversationTopic from the subject rather than
10
+ * from References, so an Ignore Conversation applied once applied to every enquiry the
11
+ * site would ever receive.
12
+ *
13
+ * A per-conversation reference is what stops one filtering decision swallowing the lot.
14
+ */
15
+
16
+ const THREAD_A = 'f4c00aff-a458-40cb-8f6b-7ec66e98779c';
17
+ const THREAD_B = '34b62e11-8209-4beb-98e9-10b364bd2807';
18
+
19
+ describe('threadReference', () => {
20
+ it('is stable for a given thread', () => {
21
+ expect(threadReference(THREAD_A, 'New Roots Herbal')).toBe(
22
+ threadReference(THREAD_A, 'New Roots Herbal')
23
+ );
24
+ });
25
+
26
+ it('differs between threads, which is the entire point', () => {
27
+ expect(threadReference(THREAD_A, 'New Roots Herbal')).not.toBe(
28
+ threadReference(THREAD_B, 'New Roots Herbal')
29
+ );
30
+ });
31
+
32
+ it('reads as a ticket number rather than a hex dump', () => {
33
+ expect(threadReference(THREAD_A, 'New Roots Herbal')).toBe('NRH-VWQOLB');
34
+ expect(threadReference(THREAD_A, 'New Roots Herbal')).toMatch(/^[A-Z]{2,4}-[0-9A-Z]{6}$/);
35
+ });
36
+
37
+ it('builds initials from a multi-word site name', () => {
38
+ expect(threadReference(THREAD_A, 'New Roots Herbal')).toMatch(/^NRH-/);
39
+ expect(threadReference(THREAD_A, 'The Corner Shop')).toMatch(/^TCS-/);
40
+ });
41
+
42
+ it('falls back to the first three letters of a one-word name', () => {
43
+ expect(threadReference(THREAD_A, 'NextBlock')).toMatch(/^NEX-/);
44
+ });
45
+
46
+ it('drops the prefix entirely when there is no usable site name', () => {
47
+ expect(threadReference(THREAD_A, '')).toBe('VWQOLB');
48
+ expect(threadReference(THREAD_A, ' ')).toBe('VWQOLB');
49
+ expect(threadReference(THREAD_A)).toBe('VWQOLB');
50
+ });
51
+
52
+ it('ignores punctuation in the site name rather than emitting it', () => {
53
+ expect(threadReference(THREAD_A, "Bob's Bikes & Co.")).toMatch(/^BBC-/);
54
+ });
55
+
56
+ it('survives a malformed id instead of throwing', () => {
57
+ expect(threadReference('', 'New Roots Herbal')).toBe('NRH-000000');
58
+ expect(threadReference('zzzzzzzz', 'New Roots Herbal')).toBe('NRH-000000');
59
+ });
60
+
61
+ it('keeps distinct conversations apart across a large sample', () => {
62
+ const seen = new Set<string>();
63
+ for (let i = 0; i < 2000; i += 1) {
64
+ seen.add(threadReference(crypto.randomUUID(), 'New Roots Herbal'));
65
+ }
66
+ // 2.2 billion references; a handful of collisions in 2000 draws would signal a bug
67
+ // in the fold, not birthday-paradox noise.
68
+ expect(seen.size).toBe(2000);
69
+ });
70
+ });