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,412 +1,412 @@
1
- import {
2
- buildSeoDocument,
3
- emptySeoDocument,
4
- isExternalHref,
5
- tokenizeWords,
6
- type SeoDocument,
7
- type SeoHeading,
8
- type SeoHeadingLevel,
9
- type SeoImage,
10
- type SeoLink,
11
- } from '@nextblock-cms/utils/seo';
12
-
13
- /**
14
- * Flattens a page's blocks into the single `SeoDocument` the audit grades.
15
- *
16
- * The SEO engine in `@nextblock-cms/utils/seo` was written against one document
17
- * that represents one page, but the CMS does not store a page as one document:
18
- * it stores a list of block rows, each holding its own slice of content in its
19
- * own differently-named field, with most of a real page's copy nested two
20
- * levels down inside a `section` block's columns. Auditing those rows one at a
21
- * time is what produced the finding this module exists to retire — "this page
22
- * has no H1 heading" reported against a single paragraph, when the H1 was sitting
23
- * in a `heading` block two rows above and the auditor was never shown it.
24
- *
25
- * So this walks the whole list in document order and merges everything into one
26
- * document, which lets `auditSeo` answer the questions it was designed to
27
- * answer — is there exactly one H1, is there enough copy, where does the
28
- * keyphrase fall — about the page a visitor actually reads.
29
- *
30
- * Two properties are load-bearing and every change here has to preserve them:
31
- *
32
- * - **Nothing throws.** `content` is `Json` straight out of Postgres, and the
33
- * editor hands us half-typed state on every keystroke, so a field can be
34
- * null, a number, an array where an object was expected, or absent. Every
35
- * read below tolerates that and skips rather than reporting. An unrecognised
36
- * block — a custom block, whose content shape is whatever its author defined
37
- * — is skipped for the same reason: guessing at arbitrary keys would put the
38
- * author's internal configuration strings into the page's word count.
39
- * - **It stays linear and allocation-light.** This runs behind the analysis
40
- * panel's debounce, so it is re-run every time the author pauses typing. It
41
- * makes one pass over the tree, reuses the image and link objects the HTML
42
- * reader already built instead of copying them, and joins the text exactly
43
- * once at the end.
44
- *
45
- * One known and accepted limitation: `buildSeoDocument` records where blocks
46
- * divide the token stream so a multi-word keyphrase cannot be counted across a
47
- * gap the reader can see, and that side table is keyed on the document object
48
- * it built, so the merged document here does not carry one. The consequence is
49
- * narrow — a phrase whose words happen to straddle the join between two blocks
50
- * can be counted once too often — and it is the same fallback any hand-built
51
- * document has always had. Correcting it needs a way to register boundaries for
52
- * an assembled document, which is a change to the engine, not to this walker.
53
- */
54
-
55
- /**
56
- * How deep the walk will follow nested containers before giving up.
57
- *
58
- * Sections can hold sections, so the structure is genuinely recursive and has
59
- * no schema-enforced ceiling. Eight levels is far past anything a human builds
60
- * and cheap to enforce, and it means a corrupt row that somehow refers back
61
- * into itself costs a bounded walk instead of a blown stack in the editor.
62
- */
63
- const MAX_BLOCK_NESTING_DEPTH = 8;
64
-
65
- /**
66
- * The heading level used when a `heading` block's level is missing or corrupt.
67
- *
68
- * This mirrors `HeadingBlockRenderer`, which falls back to an H2 for anything
69
- * outside 1-6. The audit has to grade the outline the visitor is served, and
70
- * defaulting to 1 instead would invent an H1 that is nowhere on the page — and
71
- * then report a second, entirely fictional H1 as a duplicate.
72
- */
73
- const FALLBACK_HEADING_LEVEL: SeoHeadingLevel = 2;
74
-
75
- /** The accumulating page document, before its parts are joined. */
76
- interface PageDocumentDraft {
77
- headings: SeoHeading[];
78
- images: SeoImage[];
79
- links: SeoLink[];
80
- /**
81
- * Each block's text, kept apart until the end and then joined with a single
82
- * space. Concatenating without a separator would glue the last word of one
83
- * block onto the first word of the next ("…our coffee" + "beans are…" reading
84
- * as the single token "coffeebeans"), which corrupts the word count, the
85
- * keyphrase density and the readability sample all at once.
86
- */
87
- textParts: string[];
88
- words: string[];
89
- }
90
-
91
- function createDraft(): PageDocumentDraft {
92
- return { headings: [], images: [], links: [], textParts: [], words: [] };
93
- }
94
-
95
- /** Narrows to a plain object, which is the only shape a block or content has. */
96
- function readRecord(value: unknown): Record<string, unknown> | null {
97
- return typeof value === 'object' && value !== null && !Array.isArray(value)
98
- ? (value as Record<string, unknown>)
99
- : null;
100
- }
101
-
102
- /**
103
- * Reads a field that is supposed to hold display text.
104
- *
105
- * Whitespace is collapsed to single spaces to match `SeoDocument.text`, which
106
- * the readability pass tokenises on the assumption that it already has been.
107
- * Anything that is not a string reads as empty rather than being coerced,
108
- * because `String(someObject)` would put "[object Object]" on the page.
109
- */
110
- function readText(value: unknown): string {
111
- return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
112
- }
113
-
114
- /** Clamps a stored heading level onto the six levels HTML actually has. */
115
- function readHeadingLevel(value: unknown): SeoHeadingLevel {
116
- const level = typeof value === 'number' ? Math.trunc(value) : Number.NaN;
117
-
118
- return Number.isFinite(level) && level >= 1 && level <= 6
119
- ? (level as SeoHeadingLevel)
120
- : FALLBACK_HEADING_LEVEL;
121
- }
122
-
123
- /** Adds a run of plain text, keeping the token stream in step with the text. */
124
- function appendText(draft: PageDocumentDraft, text: string): void {
125
- if (text === '') {
126
- return;
127
- }
128
-
129
- draft.textParts.push(text);
130
- for (const word of tokenizeWords(text)) {
131
- draft.words.push(word);
132
- }
133
- }
134
-
135
- /**
136
- * Folds a document the engine already built — one rich-text block's HTML or
137
- * Tiptap JSON — into the page draft.
138
- *
139
- * Headings are renumbered as they land so `order` counts positions on the page
140
- * rather than positions within whichever block happened to contain them; the
141
- * audit uses that number to say where the H1 sits, and a per-block number would
142
- * point the author at the wrong heading.
143
- */
144
- function appendDocument(draft: PageDocumentDraft, document: SeoDocument): void {
145
- for (const heading of document.headings) {
146
- draft.headings.push({ level: heading.level, order: draft.headings.length, text: heading.text });
147
- }
148
-
149
- for (const image of document.images) {
150
- draft.images.push(image);
151
- }
152
-
153
- for (const link of document.links) {
154
- draft.links.push(link);
155
- }
156
-
157
- if (document.text !== '') {
158
- draft.textParts.push(document.text);
159
- }
160
-
161
- // `words` is taken from the sub-document rather than re-tokenised from its
162
- // text: the engine guarantees the two agree, and re-tokenising would double
163
- // the work on the largest blocks on the page for an identical answer.
164
- for (const word of document.words) {
165
- draft.words.push(word);
166
- }
167
- }
168
-
169
- /** Adds one heading block, which is a standalone row rather than inline markup. */
170
- function collectHeadingBlock(content: Record<string, unknown>, draft: PageDocumentDraft): void {
171
- // Not run through an HTML stripper, unlike a rich-text block: the renderer
172
- // prints `text_content` as a React child, so any markup in it is escaped and
173
- // the visitor sees the angle brackets. Stripping here would analyse text the
174
- // page never shows.
175
- const text = readText(content['text_content']);
176
-
177
- draft.headings.push({
178
- level: readHeadingLevel(content['level']),
179
- order: draft.headings.length,
180
- text,
181
- });
182
-
183
- // A heading is also words on the page. Leaving it out of the text would make
184
- // headings free of charge in the word count and invisible to the keyphrase
185
- // density, even though they are the most heavily weighted copy on the page.
186
- appendText(draft, text);
187
- }
188
-
189
- /**
190
- * Adds one image block.
191
- *
192
- * The source is resolved the way `ImageBlockRenderer` resolves it — an external
193
- * URL wins, otherwise the R2 object key — and a block with neither is not
194
- * recorded as an image at all, because that block renders a "media not
195
- * selected" placeholder. Reporting it would raise "an image is missing alt
196
- * text" about something the visitor never sees as an image, which is precisely
197
- * the class of false finding this module was written to remove.
198
- */
199
- function collectImageBlock(content: Record<string, unknown>, draft: PageDocumentDraft): void {
200
- const externalUrl = readText(content['external_url']);
201
- const objectKey = readText(content['object_key']);
202
- const source = externalUrl !== '' ? externalUrl : objectKey;
203
-
204
- if (source === '') {
205
- return;
206
- }
207
-
208
- draft.images.push({ alt: readText(content['alt_text']), src: source });
209
-
210
- // The caption is rendered in a <figcaption> under the image, so it is prose a
211
- // reader and a crawler both see, and it belongs in the page's text.
212
- appendText(draft, readText(content['caption']));
213
- }
214
-
215
- /** Adds one button block: a link the visitor can follow, with a visible label. */
216
- function collectButtonBlock(content: Record<string, unknown>, draft: PageDocumentDraft): void {
217
- const text = readText(content['text']);
218
- const href = readText(content['url']);
219
-
220
- if (href !== '') {
221
- draft.links.push({ external: isExternalHref(href), href, text });
222
- }
223
-
224
- appendText(draft, text);
225
- }
226
-
227
- /** Adds a testimonial's visible quote and attribution. */
228
- function collectTestimonialBlock(
229
- content: Record<string, unknown>,
230
- draft: PageDocumentDraft
231
- ): void {
232
- appendText(draft, readText(content['quote']));
233
- appendText(draft, readText(content['author_name']));
234
- appendText(draft, readText(content['author_title']));
235
- }
236
-
237
- /**
238
- * Walks `column_blocks`, an array of columns each holding an array of blocks.
239
- *
240
- * Column order then block order is the order the page reads in on a phone,
241
- * where the columns stack, and it is the only total order that exists for this
242
- * shape. Getting it wrong would not lose any content, but it would misreport
243
- * which heading comes first and where in the copy the keyphrase falls.
244
- */
245
- function collectColumnBlocks(value: unknown, draft: PageDocumentDraft, depth: number): void {
246
- if (!Array.isArray(value)) {
247
- return;
248
- }
249
-
250
- for (const column of value) {
251
- if (!Array.isArray(column)) {
252
- continue;
253
- }
254
-
255
- for (const block of column) {
256
- collectBlock(block, draft, depth + 1);
257
- }
258
- }
259
- }
260
-
261
- /**
262
- * Walks a section, which is where most of a real page's content lives.
263
- *
264
- * Its children are not rows in the blocks array — they are plain
265
- * `{ block_type, content }` objects buried in `content.column_blocks` — so a
266
- * walker that only looked at the top-level list would under-report almost every
267
- * page ever built in this CMS.
268
- *
269
- * A section in slider mode renders its slides *instead of* its columns, and
270
- * this mirrors that: walking both would count copy from a stale column set the
271
- * visitor cannot reach, and inflate the word count with it.
272
- */
273
- function collectSectionBlock(
274
- content: Record<string, unknown>,
275
- draft: PageDocumentDraft,
276
- depth: number
277
- ): void {
278
- const slides = content['slides'];
279
-
280
- if (content['slider'] === true && Array.isArray(slides) && slides.length > 0) {
281
- for (const slide of slides) {
282
- const record = readRecord(slide);
283
- if (record !== null) {
284
- collectColumnBlocks(record['column_blocks'], draft, depth);
285
- }
286
- }
287
-
288
- return;
289
- }
290
-
291
- collectColumnBlocks(content['column_blocks'], draft, depth);
292
- }
293
-
294
- /** Dispatches one block row onto the reader that knows where its text lives. */
295
- function collectBlock(value: unknown, draft: PageDocumentDraft, depth: number): void {
296
- if (depth > MAX_BLOCK_NESTING_DEPTH) {
297
- return;
298
- }
299
-
300
- const block = readRecord(value);
301
- if (block === null) {
302
- return;
303
- }
304
-
305
- const blockType = typeof block['block_type'] === 'string' ? block['block_type'] : '';
306
- const content = readRecord(block['content']);
307
- if (content === null) {
308
- return;
309
- }
310
-
311
- switch (blockType) {
312
- case 'text': {
313
- // `html_content` holds either an HTML string or a JSON-stringified Tiptap
314
- // document depending on when and where the block was authored.
315
- // `buildSeoDocument` already distinguishes the two and reads both, so
316
- // this delegates rather than growing a second parser that could disagree
317
- // with the one the block-level panel uses. `text_content` is the shape
318
- // some older rows still carry.
319
- const source = content['html_content'] ?? content['text_content'];
320
- appendDocument(draft, buildSeoDocument(source));
321
-
322
- return;
323
- }
324
-
325
- case 'heading':
326
- collectHeadingBlock(content, draft);
327
-
328
- return;
329
-
330
- case 'image':
331
- collectImageBlock(content, draft);
332
-
333
- return;
334
-
335
- case 'button':
336
- collectButtonBlock(content, draft);
337
-
338
- return;
339
-
340
- case 'testimonial':
341
- collectTestimonialBlock(content, draft);
342
-
343
- return;
344
-
345
- // The registry calls this `video_embed`; `video` is accepted alongside it
346
- // because that is the name the block is known by in the UI and in prompts,
347
- // and both carry the same optional `title`.
348
- case 'video':
349
- case 'video_embed':
350
- appendText(draft, readText(content['title']));
351
-
352
- return;
353
-
354
- case 'section':
355
- collectSectionBlock(content, draft, depth);
356
-
357
- return;
358
-
359
- // `hero` is not in the current registry, but rows created before sections
360
- // absorbed it still exist in live databases and still render, so their copy
361
- // still counts. They keep both shapes: slides and a plain column set.
362
- case 'hero': {
363
- const slides = content['slides'];
364
- if (Array.isArray(slides)) {
365
- for (const slide of slides) {
366
- const record = readRecord(slide);
367
- if (record !== null) {
368
- collectColumnBlocks(record['column_blocks'], draft, depth);
369
- }
370
- }
371
- }
372
-
373
- collectColumnBlocks(content['column_blocks'], draft, depth);
374
-
375
- return;
376
- }
377
-
378
- default:
379
- // Every other block type — posts grids, forms, the commerce blocks, and
380
- // any custom block whose content is defined by whoever built it — renders
381
- // from data this walker cannot read, so it contributes nothing rather
382
- // than contributing a guess.
383
- return;
384
- }
385
- }
386
-
387
- /**
388
- * Builds one `SeoDocument` from a page's or post's block list.
389
- *
390
- * `blocks` is deliberately typed `unknown`: it comes from form state, from a
391
- * draft row, or straight from Supabase as `Json`, and pretending at the
392
- * signature that it is already an array of well-formed rows would only push the
393
- * validation somewhere that has less context to do it in.
394
- */
395
- export function buildPageSeoDocument(blocks: unknown): SeoDocument {
396
- if (!Array.isArray(blocks) || blocks.length === 0) {
397
- return emptySeoDocument();
398
- }
399
-
400
- const draft = createDraft();
401
- for (const block of blocks) {
402
- collectBlock(block, draft, 0);
403
- }
404
-
405
- return {
406
- headings: draft.headings,
407
- images: draft.images,
408
- links: draft.links,
409
- text: draft.textParts.join(' '),
410
- words: draft.words,
411
- };
412
- }
1
+ import {
2
+ buildSeoDocument,
3
+ emptySeoDocument,
4
+ isExternalHref,
5
+ tokenizeWords,
6
+ type SeoDocument,
7
+ type SeoHeading,
8
+ type SeoHeadingLevel,
9
+ type SeoImage,
10
+ type SeoLink,
11
+ } from '@nextblock-cms/utils/seo';
12
+
13
+ /**
14
+ * Flattens a page's blocks into the single `SeoDocument` the audit grades.
15
+ *
16
+ * The SEO engine in `@nextblock-cms/utils/seo` was written against one document
17
+ * that represents one page, but the CMS does not store a page as one document:
18
+ * it stores a list of block rows, each holding its own slice of content in its
19
+ * own differently-named field, with most of a real page's copy nested two
20
+ * levels down inside a `section` block's columns. Auditing those rows one at a
21
+ * time is what produced the finding this module exists to retire — "this page
22
+ * has no H1 heading" reported against a single paragraph, when the H1 was sitting
23
+ * in a `heading` block two rows above and the auditor was never shown it.
24
+ *
25
+ * So this walks the whole list in document order and merges everything into one
26
+ * document, which lets `auditSeo` answer the questions it was designed to
27
+ * answer — is there exactly one H1, is there enough copy, where does the
28
+ * keyphrase fall — about the page a visitor actually reads.
29
+ *
30
+ * Two properties are load-bearing and every change here has to preserve them:
31
+ *
32
+ * - **Nothing throws.** `content` is `Json` straight out of Postgres, and the
33
+ * editor hands us half-typed state on every keystroke, so a field can be
34
+ * null, a number, an array where an object was expected, or absent. Every
35
+ * read below tolerates that and skips rather than reporting. An unrecognised
36
+ * block — a custom block, whose content shape is whatever its author defined
37
+ * — is skipped for the same reason: guessing at arbitrary keys would put the
38
+ * author's internal configuration strings into the page's word count.
39
+ * - **It stays linear and allocation-light.** This runs behind the analysis
40
+ * panel's debounce, so it is re-run every time the author pauses typing. It
41
+ * makes one pass over the tree, reuses the image and link objects the HTML
42
+ * reader already built instead of copying them, and joins the text exactly
43
+ * once at the end.
44
+ *
45
+ * One known and accepted limitation: `buildSeoDocument` records where blocks
46
+ * divide the token stream so a multi-word keyphrase cannot be counted across a
47
+ * gap the reader can see, and that side table is keyed on the document object
48
+ * it built, so the merged document here does not carry one. The consequence is
49
+ * narrow — a phrase whose words happen to straddle the join between two blocks
50
+ * can be counted once too often — and it is the same fallback any hand-built
51
+ * document has always had. Correcting it needs a way to register boundaries for
52
+ * an assembled document, which is a change to the engine, not to this walker.
53
+ */
54
+
55
+ /**
56
+ * How deep the walk will follow nested containers before giving up.
57
+ *
58
+ * Sections can hold sections, so the structure is genuinely recursive and has
59
+ * no schema-enforced ceiling. Eight levels is far past anything a human builds
60
+ * and cheap to enforce, and it means a corrupt row that somehow refers back
61
+ * into itself costs a bounded walk instead of a blown stack in the editor.
62
+ */
63
+ const MAX_BLOCK_NESTING_DEPTH = 8;
64
+
65
+ /**
66
+ * The heading level used when a `heading` block's level is missing or corrupt.
67
+ *
68
+ * This mirrors `HeadingBlockRenderer`, which falls back to an H2 for anything
69
+ * outside 1-6. The audit has to grade the outline the visitor is served, and
70
+ * defaulting to 1 instead would invent an H1 that is nowhere on the page — and
71
+ * then report a second, entirely fictional H1 as a duplicate.
72
+ */
73
+ const FALLBACK_HEADING_LEVEL: SeoHeadingLevel = 2;
74
+
75
+ /** The accumulating page document, before its parts are joined. */
76
+ interface PageDocumentDraft {
77
+ headings: SeoHeading[];
78
+ images: SeoImage[];
79
+ links: SeoLink[];
80
+ /**
81
+ * Each block's text, kept apart until the end and then joined with a single
82
+ * space. Concatenating without a separator would glue the last word of one
83
+ * block onto the first word of the next ("…our coffee" + "beans are…" reading
84
+ * as the single token "coffeebeans"), which corrupts the word count, the
85
+ * keyphrase density and the readability sample all at once.
86
+ */
87
+ textParts: string[];
88
+ words: string[];
89
+ }
90
+
91
+ function createDraft(): PageDocumentDraft {
92
+ return { headings: [], images: [], links: [], textParts: [], words: [] };
93
+ }
94
+
95
+ /** Narrows to a plain object, which is the only shape a block or content has. */
96
+ function readRecord(value: unknown): Record<string, unknown> | null {
97
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
98
+ ? (value as Record<string, unknown>)
99
+ : null;
100
+ }
101
+
102
+ /**
103
+ * Reads a field that is supposed to hold display text.
104
+ *
105
+ * Whitespace is collapsed to single spaces to match `SeoDocument.text`, which
106
+ * the readability pass tokenises on the assumption that it already has been.
107
+ * Anything that is not a string reads as empty rather than being coerced,
108
+ * because `String(someObject)` would put "[object Object]" on the page.
109
+ */
110
+ function readText(value: unknown): string {
111
+ return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
112
+ }
113
+
114
+ /** Clamps a stored heading level onto the six levels HTML actually has. */
115
+ function readHeadingLevel(value: unknown): SeoHeadingLevel {
116
+ const level = typeof value === 'number' ? Math.trunc(value) : Number.NaN;
117
+
118
+ return Number.isFinite(level) && level >= 1 && level <= 6
119
+ ? (level as SeoHeadingLevel)
120
+ : FALLBACK_HEADING_LEVEL;
121
+ }
122
+
123
+ /** Adds a run of plain text, keeping the token stream in step with the text. */
124
+ function appendText(draft: PageDocumentDraft, text: string): void {
125
+ if (text === '') {
126
+ return;
127
+ }
128
+
129
+ draft.textParts.push(text);
130
+ for (const word of tokenizeWords(text)) {
131
+ draft.words.push(word);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Folds a document the engine already built — one rich-text block's HTML or
137
+ * Tiptap JSON — into the page draft.
138
+ *
139
+ * Headings are renumbered as they land so `order` counts positions on the page
140
+ * rather than positions within whichever block happened to contain them; the
141
+ * audit uses that number to say where the H1 sits, and a per-block number would
142
+ * point the author at the wrong heading.
143
+ */
144
+ function appendDocument(draft: PageDocumentDraft, document: SeoDocument): void {
145
+ for (const heading of document.headings) {
146
+ draft.headings.push({ level: heading.level, order: draft.headings.length, text: heading.text });
147
+ }
148
+
149
+ for (const image of document.images) {
150
+ draft.images.push(image);
151
+ }
152
+
153
+ for (const link of document.links) {
154
+ draft.links.push(link);
155
+ }
156
+
157
+ if (document.text !== '') {
158
+ draft.textParts.push(document.text);
159
+ }
160
+
161
+ // `words` is taken from the sub-document rather than re-tokenised from its
162
+ // text: the engine guarantees the two agree, and re-tokenising would double
163
+ // the work on the largest blocks on the page for an identical answer.
164
+ for (const word of document.words) {
165
+ draft.words.push(word);
166
+ }
167
+ }
168
+
169
+ /** Adds one heading block, which is a standalone row rather than inline markup. */
170
+ function collectHeadingBlock(content: Record<string, unknown>, draft: PageDocumentDraft): void {
171
+ // Not run through an HTML stripper, unlike a rich-text block: the renderer
172
+ // prints `text_content` as a React child, so any markup in it is escaped and
173
+ // the visitor sees the angle brackets. Stripping here would analyse text the
174
+ // page never shows.
175
+ const text = readText(content['text_content']);
176
+
177
+ draft.headings.push({
178
+ level: readHeadingLevel(content['level']),
179
+ order: draft.headings.length,
180
+ text,
181
+ });
182
+
183
+ // A heading is also words on the page. Leaving it out of the text would make
184
+ // headings free of charge in the word count and invisible to the keyphrase
185
+ // density, even though they are the most heavily weighted copy on the page.
186
+ appendText(draft, text);
187
+ }
188
+
189
+ /**
190
+ * Adds one image block.
191
+ *
192
+ * The source is resolved the way `ImageBlockRenderer` resolves it — an external
193
+ * URL wins, otherwise the R2 object key — and a block with neither is not
194
+ * recorded as an image at all, because that block renders a "media not
195
+ * selected" placeholder. Reporting it would raise "an image is missing alt
196
+ * text" about something the visitor never sees as an image, which is precisely
197
+ * the class of false finding this module was written to remove.
198
+ */
199
+ function collectImageBlock(content: Record<string, unknown>, draft: PageDocumentDraft): void {
200
+ const externalUrl = readText(content['external_url']);
201
+ const objectKey = readText(content['object_key']);
202
+ const source = externalUrl !== '' ? externalUrl : objectKey;
203
+
204
+ if (source === '') {
205
+ return;
206
+ }
207
+
208
+ draft.images.push({ alt: readText(content['alt_text']), src: source });
209
+
210
+ // The caption is rendered in a <figcaption> under the image, so it is prose a
211
+ // reader and a crawler both see, and it belongs in the page's text.
212
+ appendText(draft, readText(content['caption']));
213
+ }
214
+
215
+ /** Adds one button block: a link the visitor can follow, with a visible label. */
216
+ function collectButtonBlock(content: Record<string, unknown>, draft: PageDocumentDraft): void {
217
+ const text = readText(content['text']);
218
+ const href = readText(content['url']);
219
+
220
+ if (href !== '') {
221
+ draft.links.push({ external: isExternalHref(href), href, text });
222
+ }
223
+
224
+ appendText(draft, text);
225
+ }
226
+
227
+ /** Adds a testimonial's visible quote and attribution. */
228
+ function collectTestimonialBlock(
229
+ content: Record<string, unknown>,
230
+ draft: PageDocumentDraft
231
+ ): void {
232
+ appendText(draft, readText(content['quote']));
233
+ appendText(draft, readText(content['author_name']));
234
+ appendText(draft, readText(content['author_title']));
235
+ }
236
+
237
+ /**
238
+ * Walks `column_blocks`, an array of columns each holding an array of blocks.
239
+ *
240
+ * Column order then block order is the order the page reads in on a phone,
241
+ * where the columns stack, and it is the only total order that exists for this
242
+ * shape. Getting it wrong would not lose any content, but it would misreport
243
+ * which heading comes first and where in the copy the keyphrase falls.
244
+ */
245
+ function collectColumnBlocks(value: unknown, draft: PageDocumentDraft, depth: number): void {
246
+ if (!Array.isArray(value)) {
247
+ return;
248
+ }
249
+
250
+ for (const column of value) {
251
+ if (!Array.isArray(column)) {
252
+ continue;
253
+ }
254
+
255
+ for (const block of column) {
256
+ collectBlock(block, draft, depth + 1);
257
+ }
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Walks a section, which is where most of a real page's content lives.
263
+ *
264
+ * Its children are not rows in the blocks array — they are plain
265
+ * `{ block_type, content }` objects buried in `content.column_blocks` — so a
266
+ * walker that only looked at the top-level list would under-report almost every
267
+ * page ever built in this CMS.
268
+ *
269
+ * A section in slider mode renders its slides *instead of* its columns, and
270
+ * this mirrors that: walking both would count copy from a stale column set the
271
+ * visitor cannot reach, and inflate the word count with it.
272
+ */
273
+ function collectSectionBlock(
274
+ content: Record<string, unknown>,
275
+ draft: PageDocumentDraft,
276
+ depth: number
277
+ ): void {
278
+ const slides = content['slides'];
279
+
280
+ if (content['slider'] === true && Array.isArray(slides) && slides.length > 0) {
281
+ for (const slide of slides) {
282
+ const record = readRecord(slide);
283
+ if (record !== null) {
284
+ collectColumnBlocks(record['column_blocks'], draft, depth);
285
+ }
286
+ }
287
+
288
+ return;
289
+ }
290
+
291
+ collectColumnBlocks(content['column_blocks'], draft, depth);
292
+ }
293
+
294
+ /** Dispatches one block row onto the reader that knows where its text lives. */
295
+ function collectBlock(value: unknown, draft: PageDocumentDraft, depth: number): void {
296
+ if (depth > MAX_BLOCK_NESTING_DEPTH) {
297
+ return;
298
+ }
299
+
300
+ const block = readRecord(value);
301
+ if (block === null) {
302
+ return;
303
+ }
304
+
305
+ const blockType = typeof block['block_type'] === 'string' ? block['block_type'] : '';
306
+ const content = readRecord(block['content']);
307
+ if (content === null) {
308
+ return;
309
+ }
310
+
311
+ switch (blockType) {
312
+ case 'text': {
313
+ // `html_content` holds either an HTML string or a JSON-stringified Tiptap
314
+ // document depending on when and where the block was authored.
315
+ // `buildSeoDocument` already distinguishes the two and reads both, so
316
+ // this delegates rather than growing a second parser that could disagree
317
+ // with the one the block-level panel uses. `text_content` is the shape
318
+ // some older rows still carry.
319
+ const source = content['html_content'] ?? content['text_content'];
320
+ appendDocument(draft, buildSeoDocument(source));
321
+
322
+ return;
323
+ }
324
+
325
+ case 'heading':
326
+ collectHeadingBlock(content, draft);
327
+
328
+ return;
329
+
330
+ case 'image':
331
+ collectImageBlock(content, draft);
332
+
333
+ return;
334
+
335
+ case 'button':
336
+ collectButtonBlock(content, draft);
337
+
338
+ return;
339
+
340
+ case 'testimonial':
341
+ collectTestimonialBlock(content, draft);
342
+
343
+ return;
344
+
345
+ // The registry calls this `video_embed`; `video` is accepted alongside it
346
+ // because that is the name the block is known by in the UI and in prompts,
347
+ // and both carry the same optional `title`.
348
+ case 'video':
349
+ case 'video_embed':
350
+ appendText(draft, readText(content['title']));
351
+
352
+ return;
353
+
354
+ case 'section':
355
+ collectSectionBlock(content, draft, depth);
356
+
357
+ return;
358
+
359
+ // `hero` is not in the current registry, but rows created before sections
360
+ // absorbed it still exist in live databases and still render, so their copy
361
+ // still counts. They keep both shapes: slides and a plain column set.
362
+ case 'hero': {
363
+ const slides = content['slides'];
364
+ if (Array.isArray(slides)) {
365
+ for (const slide of slides) {
366
+ const record = readRecord(slide);
367
+ if (record !== null) {
368
+ collectColumnBlocks(record['column_blocks'], draft, depth);
369
+ }
370
+ }
371
+ }
372
+
373
+ collectColumnBlocks(content['column_blocks'], draft, depth);
374
+
375
+ return;
376
+ }
377
+
378
+ default:
379
+ // Every other block type — posts grids, forms, the commerce blocks, and
380
+ // any custom block whose content is defined by whoever built it — renders
381
+ // from data this walker cannot read, so it contributes nothing rather
382
+ // than contributing a guess.
383
+ return;
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Builds one `SeoDocument` from a page's or post's block list.
389
+ *
390
+ * `blocks` is deliberately typed `unknown`: it comes from form state, from a
391
+ * draft row, or straight from Supabase as `Json`, and pretending at the
392
+ * signature that it is already an array of well-formed rows would only push the
393
+ * validation somewhere that has less context to do it in.
394
+ */
395
+ export function buildPageSeoDocument(blocks: unknown): SeoDocument {
396
+ if (!Array.isArray(blocks) || blocks.length === 0) {
397
+ return emptySeoDocument();
398
+ }
399
+
400
+ const draft = createDraft();
401
+ for (const block of blocks) {
402
+ collectBlock(block, draft, 0);
403
+ }
404
+
405
+ return {
406
+ headings: draft.headings,
407
+ images: draft.images,
408
+ links: draft.links,
409
+ text: draft.textParts.join(' '),
410
+ words: draft.words,
411
+ };
412
+ }