create-nextblock 0.16.1 → 0.16.3

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 (264) 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 -221
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
  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/route.ts +4 -4
  30. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4542 -2904
  31. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  32. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  33. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  34. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  35. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  37. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  38. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  39. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  40. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  41. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
  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 +406 -406
  46. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  47. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  48. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  49. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  50. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  51. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  52. package/templates/nextblock-template/app/cms/components/SeoScoreBadge.tsx +40 -0
  53. package/templates/nextblock-template/app/cms/components/TablePagination.tsx +136 -0
  54. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  55. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  56. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  58. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  59. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  60. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  61. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  63. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  64. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  65. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  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/components/PageForm.tsx +649 -649
  74. package/templates/nextblock-template/app/cms/pages/page.tsx +273 -227
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +2 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +624 -618
  77. package/templates/nextblock-template/app/cms/posts/page.tsx +251 -194
  78. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  79. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  80. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  81. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  82. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  83. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  84. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  85. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  86. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  90. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  91. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  92. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  93. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  96. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  98. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  99. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  100. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  101. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  102. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  103. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  104. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  105. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  106. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  107. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  108. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  109. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  110. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  111. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  112. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  113. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  114. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  115. package/templates/nextblock-template/app/layout.tsx +671 -671
  116. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  117. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  118. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  119. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  120. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  121. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  122. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  123. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  124. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  125. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  126. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  127. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  128. package/templates/nextblock-template/app/providers.tsx +96 -96
  129. package/templates/nextblock-template/app/robots.ts +123 -123
  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 -137
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +251 -244
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  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 -86
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  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 -154
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +157 -140
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +384 -350
  231. package/templates/nextblock-template/lib/seo/page-document.ts +463 -412
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +37 -22
  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 +1 -2
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/scripts/backup.js +115 -115
  256. package/templates/nextblock-template/scripts/restore.js +385 -385
  257. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  258. package/templates/nextblock-template/tailwind.config.js +25 -25
  259. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  260. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  261. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  262. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  263. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  264. package/tsconfig.base.json +3 -3
@@ -1,350 +1,384 @@
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
- });
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
+
351
+ it('treats post title as H1 when documentType is post', () => {
352
+ const blocks = [
353
+ headingBlock(2, 'Overview'),
354
+ textBlock('<p>Detailed article content goes here.</p>'),
355
+ ];
356
+
357
+ const withoutOptions = buildPageSeoDocument(blocks);
358
+ expect(withoutOptions.headings.map((h) => h.level)).toEqual([2]);
359
+
360
+ const withPost = buildPageSeoDocument(blocks, {
361
+ documentTitle: 'My Great Article',
362
+ documentType: 'post',
363
+ });
364
+ expect(withPost.headings).toEqual([
365
+ { level: 1, order: 0, text: 'My Great Article' },
366
+ { level: 2, order: 1, text: 'Overview' },
367
+ ]);
368
+ expect(withPost.words).toContain('my');
369
+ expect(withPost.words).toContain('article');
370
+
371
+ const audit = auditSeo({ document: withPost });
372
+ expect(audit.issues.map((i) => i.id)).not.toContain('headings-missing-h1');
373
+ });
374
+
375
+ it('handles empty blocks with post title', () => {
376
+ const doc = buildPageSeoDocument([], {
377
+ documentTitle: 'Initial Draft Post',
378
+ documentType: 'post',
379
+ });
380
+ expect(doc.headings).toEqual([{ level: 1, order: 0, text: 'Initial Draft Post' }]);
381
+ expect(doc.text).toBe('Initial Draft Post');
382
+ });
383
+ });
384
+