create-nextblock 0.16.1 → 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 (258) 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/sandboxResetSql.ts +3092 -3092
  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 -558
  41. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  42. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  43. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  44. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
  45. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  46. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  47. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  48. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  49. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  50. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  51. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  52. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  53. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  54. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  55. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  56. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  57. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  58. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  59. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  60. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  61. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  62. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  63. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  64. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  65. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  66. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  67. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  68. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  69. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  70. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
  71. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -618
  72. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  73. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  74. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  75. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  76. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  77. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  78. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  79. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  80. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  81. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  82. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  83. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  84. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  85. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  86. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  87. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  88. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  89. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  90. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  91. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  92. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  93. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  94. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  95. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  96. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  97. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  98. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  99. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  100. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  101. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  102. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  103. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  104. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  105. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  106. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  107. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  108. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  109. package/templates/nextblock-template/app/layout.tsx +671 -671
  110. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  111. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  112. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  113. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  114. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  115. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  116. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  117. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  118. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  119. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  120. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  121. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  122. package/templates/nextblock-template/app/providers.tsx +96 -96
  123. package/templates/nextblock-template/app/robots.ts +123 -123
  124. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  125. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  126. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  127. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  128. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  129. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  130. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  131. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  132. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  133. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  134. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  135. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  136. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  137. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  138. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  139. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  140. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  141. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  142. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  143. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  144. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  145. package/templates/nextblock-template/components/Header.tsx +38 -38
  146. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  147. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  148. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  149. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  150. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  151. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  152. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  153. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  154. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  155. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  156. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  157. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  158. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  159. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  160. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  161. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  162. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  163. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  164. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  165. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  166. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  167. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  168. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  169. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  170. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  171. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  172. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  173. package/templates/nextblock-template/components/form-message.tsx +32 -32
  174. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
  175. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -244
  176. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  177. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  178. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  179. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  180. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  181. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  182. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  183. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  184. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  185. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  186. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  187. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  188. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  189. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  190. package/templates/nextblock-template/docs/README.md +42 -42
  191. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  192. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  193. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  194. package/templates/nextblock-template/index.d.ts +7 -7
  195. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  196. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  197. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  198. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  199. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  200. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  201. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  202. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  203. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  204. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  205. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  206. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  207. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
  208. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  209. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  210. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  211. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  212. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  213. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  214. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  215. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  216. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  217. package/templates/nextblock-template/lib/search/types.ts +27 -27
  218. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
  219. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  220. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  221. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  222. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  223. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -140
  224. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -350
  225. package/templates/nextblock-template/lib/seo/page-document.ts +412 -412
  226. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  227. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  228. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  229. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  230. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  231. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  232. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -177
  233. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  234. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  235. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  236. package/templates/nextblock-template/lib/site-url.ts +102 -102
  237. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  238. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  239. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  240. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  241. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  242. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  243. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  244. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  245. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  246. package/templates/nextblock-template/next-env.d.ts +1 -2
  247. package/templates/nextblock-template/package.json +1 -1
  248. package/templates/nextblock-template/postcss.config.js +6 -6
  249. package/templates/nextblock-template/scripts/backup.js +115 -115
  250. package/templates/nextblock-template/scripts/restore.js +385 -385
  251. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  252. package/templates/nextblock-template/tailwind.config.js +25 -25
  253. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  254. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  255. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  256. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  257. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  258. package/tsconfig.base.json +3 -3
@@ -1,350 +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
- });
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
+ });