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
@@ -0,0 +1,350 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { auditSeo, emptySeoDocument, tokenizeWords } from '@nextblock-cms/utils/seo';
3
+ import { buildPageSeoDocument } from './page-document';
4
+
5
+ /**
6
+ * The walker is the piece that decides what the page-level audit is allowed to
7
+ * see, so these tests are written against the block shapes the CMS actually
8
+ * stores — `{ block_type, content }` rows, with a section's children nested in
9
+ * `content.column_blocks[column][index]` rather than sitting in the row list.
10
+ *
11
+ * Two things are asserted over and over on purpose. Content must be *found*,
12
+ * because a walker that quietly misses a container under-reports the page and
13
+ * hands the author a score for copy that was never read; and malformed content
14
+ * must be *survived*, because this runs on every keystroke over half-typed
15
+ * editor state where any field can be null or the wrong type.
16
+ */
17
+
18
+ /** A rich-text block as the editor stores it when the content is HTML. */
19
+ function textBlock(html: string): unknown {
20
+ return { block_type: 'text', content: { html_content: html } };
21
+ }
22
+
23
+ /** A standalone heading block, the shape a per-block audit could never see. */
24
+ function headingBlock(level: number, text: string): unknown {
25
+ return { block_type: 'heading', content: { level, text_content: text } };
26
+ }
27
+
28
+ /** A section, whose children are plain objects inside its columns. */
29
+ function sectionBlock(columns: unknown[][]): unknown {
30
+ return { block_type: 'section', content: { column_blocks: columns } };
31
+ }
32
+
33
+ describe('buildPageSeoDocument on an empty or unusable input', () => {
34
+ it('returns an empty document rather than throwing', () => {
35
+ const empty = emptySeoDocument();
36
+
37
+ expect(buildPageSeoDocument([])).toEqual(empty);
38
+ expect(buildPageSeoDocument(null)).toEqual(empty);
39
+ expect(buildPageSeoDocument(undefined)).toEqual(empty);
40
+ expect(buildPageSeoDocument('not blocks')).toEqual(empty);
41
+ expect(buildPageSeoDocument({ block_type: 'text' })).toEqual(empty);
42
+ });
43
+ });
44
+
45
+ describe('buildPageSeoDocument across sibling blocks', () => {
46
+ const document = buildPageSeoDocument([
47
+ headingBlock(1, 'Fresh Cat Food'),
48
+ textBlock('<p>We ship every order fast.</p>'),
49
+ headingBlock(2, 'Why It Works'),
50
+ ]);
51
+
52
+ it('merges every block into a single document with the headings in order', () => {
53
+ expect(document.headings).toEqual([
54
+ { level: 1, order: 0, text: 'Fresh Cat Food' },
55
+ { level: 2, order: 1, text: 'Why It Works' },
56
+ ]);
57
+ });
58
+
59
+ it('renumbers `order` sequentially across the whole page', () => {
60
+ // Each block starts its own numbering, so the page-level order can only
61
+ // come from the walker. It is the number the audit cites when it tells an
62
+ // author where their H1 sits, so an off-by-one here points at the wrong
63
+ // heading.
64
+ const withInlineHeadings = buildPageSeoDocument([
65
+ textBlock('<h2>Inline one</h2><h3>Inline two</h3>'),
66
+ headingBlock(2, 'Standalone three'),
67
+ ]);
68
+
69
+ expect(withInlineHeadings.headings.map((heading) => heading.order)).toEqual([0, 1, 2]);
70
+ expect(withInlineHeadings.headings[2]?.text).toBe('Standalone three');
71
+ });
72
+
73
+ it('keeps the text of adjacent blocks from gluing into one word', () => {
74
+ // "…our coffee" followed by "beans are…" must not tokenise as "coffeebeans":
75
+ // that single fabricated word would corrupt the word count, the keyphrase
76
+ // density and the readability sample in one go.
77
+ const glued = buildPageSeoDocument([
78
+ textBlock('<p>We roast our coffee</p>'),
79
+ textBlock('<p>beans are fresh</p>'),
80
+ ]);
81
+
82
+ expect(glued.words).toEqual(['we', 'roast', 'our', 'coffee', 'beans', 'are', 'fresh']);
83
+ expect(glued.text).toBe('We roast our coffee beans are fresh');
84
+ });
85
+
86
+ it('keeps `words` in step with `text`', () => {
87
+ // Every downstream metric reads one or the other and assumes they describe
88
+ // the same prose: readability tokenises `text`, the density divides by
89
+ // `words.length`.
90
+ expect(document.words).toEqual(tokenizeWords(document.text));
91
+ });
92
+
93
+ it('counts heading copy as page text', () => {
94
+ expect(document.text).toContain('Fresh Cat Food');
95
+ expect(document.words).toContain('fresh');
96
+ });
97
+ });
98
+
99
+ describe('buildPageSeoDocument on nested sections', () => {
100
+ it('finds blocks nested inside a section column', () => {
101
+ const document = buildPageSeoDocument([
102
+ sectionBlock([[headingBlock(1, 'Nested Title'), textBlock('<p>Nested copy.</p>')]]),
103
+ ]);
104
+
105
+ expect(document.headings).toEqual([{ level: 1, order: 0, text: 'Nested Title' }]);
106
+ expect(document.text).toBe('Nested Title Nested copy.');
107
+ });
108
+
109
+ it('reads column 0 before column 1', () => {
110
+ const document = buildPageSeoDocument([
111
+ sectionBlock([[headingBlock(2, 'Left')], [headingBlock(3, 'Right')]]),
112
+ ]);
113
+
114
+ expect(document.headings.map((heading) => heading.text)).toEqual(['Left', 'Right']);
115
+ expect(document.text).toBe('Left Right');
116
+ });
117
+
118
+ it('follows a section nested inside another section', () => {
119
+ const document = buildPageSeoDocument([
120
+ sectionBlock([[sectionBlock([[textBlock('<p>Two levels down.</p>')]])]]),
121
+ ]);
122
+
123
+ expect(document.text).toBe('Two levels down.');
124
+ });
125
+
126
+ it('reads a slider section from its slides instead of its columns', () => {
127
+ // A section in slider mode renders its slides and ignores whatever is left
128
+ // in `column_blocks`, so counting both would credit the page with copy no
129
+ // visitor can reach.
130
+ const document = buildPageSeoDocument([
131
+ {
132
+ block_type: 'section',
133
+ content: {
134
+ column_blocks: [[textBlock('<p>Stale hidden copy.</p>')]],
135
+ slider: true,
136
+ slides: [{ column_blocks: [[textBlock('<p>Visible slide copy.</p>')]] }],
137
+ },
138
+ },
139
+ ]);
140
+
141
+ expect(document.text).toBe('Visible slide copy.');
142
+ });
143
+
144
+ it('still reads a legacy hero block, which stores its children in slides', () => {
145
+ const document = buildPageSeoDocument([
146
+ {
147
+ block_type: 'hero',
148
+ content: { slides: [{ column_blocks: [[headingBlock(1, 'Legacy Hero')]] }] },
149
+ },
150
+ ]);
151
+
152
+ expect(document.headings).toEqual([{ level: 1, order: 0, text: 'Legacy Hero' }]);
153
+ });
154
+ });
155
+
156
+ describe('buildPageSeoDocument on rich-text blocks', () => {
157
+ it('reads an html_content that holds a JSON-stringified Tiptap document', () => {
158
+ // Both storage formats are live in the same column; the app tells them apart
159
+ // by a leading brace, and so must anything that reads the column.
160
+ const tiptap = JSON.stringify({
161
+ content: [
162
+ { attrs: { level: 2 }, content: [{ text: 'Tiptap Heading', type: 'text' }], type: 'heading' },
163
+ { content: [{ text: 'Tiptap paragraph copy.', type: 'text' }], type: 'paragraph' },
164
+ ],
165
+ type: 'doc',
166
+ });
167
+
168
+ const document = buildPageSeoDocument([textBlock(tiptap)]);
169
+
170
+ expect(document.headings).toEqual([{ level: 2, order: 0, text: 'Tiptap Heading' }]);
171
+ expect(document.text).toBe('Tiptap Heading Tiptap paragraph copy.');
172
+ });
173
+
174
+ it('collects images and links embedded in prose', () => {
175
+ const document = buildPageSeoDocument([
176
+ textBlock('<p><img src="/inline.png" alt=""><a href="https://example.com">Away</a></p>'),
177
+ ]);
178
+
179
+ expect(document.images).toEqual([{ alt: '', src: '/inline.png' }]);
180
+ expect(document.links).toEqual([
181
+ { external: true, href: 'https://example.com', text: 'Away' },
182
+ ]);
183
+ });
184
+ });
185
+
186
+ describe('buildPageSeoDocument on image, button and video blocks', () => {
187
+ it('reports an image block through the same alt-text channel as inline images', () => {
188
+ const document = buildPageSeoDocument([
189
+ { block_type: 'image', content: { alt_text: 'A bowl of cat food', object_key: 'cat.png' } },
190
+ { block_type: 'image', content: { alt_text: '', external_url: 'https://cdn.test/b.png' } },
191
+ ]);
192
+
193
+ expect(document.images).toEqual([
194
+ { alt: 'A bowl of cat food', src: 'cat.png' },
195
+ { alt: '', src: 'https://cdn.test/b.png' },
196
+ ]);
197
+ });
198
+
199
+ it('prefers the external URL, which is what the renderer draws', () => {
200
+ const document = buildPageSeoDocument([
201
+ {
202
+ block_type: 'image',
203
+ content: { alt_text: 'Stock', external_url: 'https://cdn.test/a.png', object_key: 'a.png' },
204
+ },
205
+ ]);
206
+
207
+ expect(document.images[0]?.src).toBe('https://cdn.test/a.png');
208
+ });
209
+
210
+ it('ignores an image block with no source at all', () => {
211
+ // That block renders a "media not selected" placeholder, so flagging its
212
+ // missing alt text would be a complaint about an image nobody can see.
213
+ const document = buildPageSeoDocument([{ block_type: 'image', content: { alt_text: '' } }]);
214
+
215
+ expect(document.images).toEqual([]);
216
+ });
217
+
218
+ it('includes an image caption in the page text', () => {
219
+ const document = buildPageSeoDocument([
220
+ {
221
+ block_type: 'image',
222
+ content: { alt_text: 'Beans', caption: 'Beans drying in the sun.', object_key: 'b.png' },
223
+ },
224
+ ]);
225
+
226
+ expect(document.text).toBe('Beans drying in the sun.');
227
+ });
228
+
229
+ it('records a button as a link and its label as text', () => {
230
+ const document = buildPageSeoDocument([
231
+ { block_type: 'button', content: { text: 'Shop now', url: '/shop' } },
232
+ ]);
233
+
234
+ expect(document.links).toEqual([{ external: false, href: '/shop', text: 'Shop now' }]);
235
+ expect(document.text).toBe('Shop now');
236
+ });
237
+
238
+ it('keeps a button label even when no URL has been set yet', () => {
239
+ const document = buildPageSeoDocument([{ block_type: 'button', content: { text: 'Shop now' } }]);
240
+
241
+ expect(document.links).toEqual([]);
242
+ expect(document.text).toBe('Shop now');
243
+ });
244
+
245
+ it('takes a video block title as text when there is one', () => {
246
+ const document = buildPageSeoDocument([
247
+ { block_type: 'video_embed', content: { title: 'How we roast', url: 'https://v.test/1' } },
248
+ { block_type: 'video_embed', content: { url: 'https://v.test/2' } },
249
+ ]);
250
+
251
+ expect(document.text).toBe('How we roast');
252
+ });
253
+
254
+ it('takes a testimonial quote and its attribution', () => {
255
+ const document = buildPageSeoDocument([
256
+ {
257
+ block_type: 'testimonial',
258
+ content: { author_name: 'Jane Doe', author_title: 'CEO', quote: 'It changed my morning.' },
259
+ },
260
+ ]);
261
+
262
+ expect(document.text).toBe('It changed my morning. Jane Doe CEO');
263
+ });
264
+ });
265
+
266
+ describe('buildPageSeoDocument on content it cannot read', () => {
267
+ it('skips unknown and custom block types instead of guessing at their fields', () => {
268
+ const document = buildPageSeoDocument([
269
+ { block_type: 'pricing-table-v2', content: { internal_label: 'Do not index me', rows: 4 } },
270
+ { block_type: 'posts_grid', content: { columns: 3, postsPerPage: 6 } },
271
+ headingBlock(2, 'Real Heading'),
272
+ ]);
273
+
274
+ expect(document.headings).toEqual([{ level: 2, order: 0, text: 'Real Heading' }]);
275
+ expect(document.text).toBe('Real Heading');
276
+ });
277
+
278
+ it('survives every field being the wrong type or absent', () => {
279
+ const malformed: unknown[] = [
280
+ null,
281
+ undefined,
282
+ 42,
283
+ 'a string where a row should be',
284
+ [],
285
+ { block_type: 'text' },
286
+ { block_type: 'text', content: null },
287
+ { block_type: 'text', content: { html_content: 12 } },
288
+ { block_type: 'heading', content: {} },
289
+ { block_type: 'image', content: { alt_text: null, object_key: null } },
290
+ { block_type: 'button', content: { text: null, url: null } },
291
+ { block_type: 'section', content: { column_blocks: 'not an array' } },
292
+ { block_type: 'section', content: { column_blocks: [null, [null, 7]] } },
293
+ { block_type: 'section', content: {} },
294
+ { content: { html_content: '<p>No block type.</p>' } },
295
+ ];
296
+
297
+ expect(() => buildPageSeoDocument(malformed)).not.toThrow();
298
+
299
+ const document = buildPageSeoDocument(malformed);
300
+
301
+ // The one heading with no fields at all still surfaces, because an empty
302
+ // heading is a real defect the audit reports rather than a row to discard.
303
+ expect(document.headings).toEqual([{ level: 2, order: 0, text: '' }]);
304
+ expect(document.text).toBe('');
305
+ expect(document.words).toEqual([]);
306
+ });
307
+
308
+ it('stops descending once the nesting is deeper than any real page', () => {
309
+ // Guards against a corrupt row that refers back into itself: the walk is
310
+ // bounded, so the editor degrades to missing copy rather than to a blown
311
+ // stack while someone is typing.
312
+ let nested: unknown = textBlock('<p>Deepest copy.</p>');
313
+ for (let level = 0; level < 40; level += 1) {
314
+ nested = sectionBlock([[nested]]);
315
+ }
316
+
317
+ expect(() => buildPageSeoDocument([nested])).not.toThrow();
318
+ expect(buildPageSeoDocument([nested]).text).toBe('');
319
+ });
320
+ });
321
+
322
+ describe('buildPageSeoDocument feeding the page-level audit', () => {
323
+ it('no longer reports a missing H1 when the H1 lives in its own block', () => {
324
+ // This is the bug that prompted the page-level audit: the paragraph block
325
+ // was graded on its own and reported "this page has no H1" while the H1 sat
326
+ // one row above it, in a block the auditor was never shown.
327
+ const blocks = [
328
+ headingBlock(1, 'Fresh Cat Food'),
329
+ sectionBlock([[textBlock('<p>We ship every order fast and pack it with care.</p>')]]),
330
+ ];
331
+
332
+ const paragraphOnly = auditSeo({ document: buildPageSeoDocument([blocks[1]]) });
333
+ const wholePage = auditSeo({ document: buildPageSeoDocument(blocks) });
334
+
335
+ expect(paragraphOnly.issues.map((issue) => issue.id)).toContain('headings-missing-h1');
336
+ expect(wholePage.issues.map((issue) => issue.id)).not.toContain('headings-missing-h1');
337
+ expect(wholePage.headings).toEqual([{ level: 1, order: 0, text: 'Fresh Cat Food' }]);
338
+ });
339
+
340
+ it('counts the words of the whole page rather than of one paragraph', () => {
341
+ const sentence = '<p>We ship every order fast and we pack it with care for your pet today.</p>';
342
+ const blocks = new Array(30).fill(null).map(() => textBlock(sentence));
343
+
344
+ const oneBlock = auditSeo({ document: buildPageSeoDocument([blocks[0]]) });
345
+ const wholePage = auditSeo({ document: buildPageSeoDocument(blocks) });
346
+
347
+ expect(oneBlock.issues.map((issue) => issue.id)).toContain('content-thin');
348
+ expect(wholePage.issues.map((issue) => issue.id)).not.toContain('content-thin');
349
+ });
350
+ });