create-nextblock 0.16.1 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/docker-template/.dockerignore +1 -1
  2. package/libs/db/tsconfig.lib.json +3 -3
  3. package/libs/editor/tsconfig.lib.json +3 -3
  4. package/libs/ui/tsconfig.lib.json +3 -3
  5. package/libs/utils/tsconfig.json +3 -3
  6. package/package.json +50 -50
  7. package/project.json +19 -19
  8. package/templates/nextblock-template/.dockerignore +1 -1
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/AGENTS.md +9 -9
  11. package/templates/nextblock-template/CLAUDE.md +1 -1
  12. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  13. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  14. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  15. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
  16. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  17. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
  18. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
  19. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
  20. package/templates/nextblock-template/app/actions/email.ts +241 -241
  21. package/templates/nextblock-template/app/actions/formActions.ts +245 -245
  22. package/templates/nextblock-template/app/actions/interactions.ts +489 -489
  23. package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
  24. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
  25. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -221
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +4 -4
  30. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4542 -2904
  31. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  32. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  33. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  34. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  35. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  37. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  38. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  39. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  40. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  41. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
  42. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  43. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  44. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  45. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
  46. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  47. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  48. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  49. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  50. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  51. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  52. package/templates/nextblock-template/app/cms/components/SeoScoreBadge.tsx +40 -0
  53. package/templates/nextblock-template/app/cms/components/TablePagination.tsx +136 -0
  54. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  55. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  56. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  58. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  59. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  60. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  61. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  63. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  64. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  65. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  66. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  67. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  68. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  69. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  70. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  71. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  72. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  73. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
  74. package/templates/nextblock-template/app/cms/pages/page.tsx +273 -227
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +2 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +624 -618
  77. package/templates/nextblock-template/app/cms/posts/page.tsx +251 -194
  78. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  79. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  80. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  81. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  82. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  83. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  84. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  85. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  86. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  90. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  91. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  92. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  93. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  96. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  98. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  99. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  100. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  101. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  102. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  103. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  104. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  105. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  106. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  107. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  108. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  109. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  110. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  111. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  112. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  113. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  114. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  115. package/templates/nextblock-template/app/layout.tsx +671 -671
  116. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  117. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  118. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  119. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  120. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  121. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  122. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  123. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  124. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  125. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  126. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  127. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  128. package/templates/nextblock-template/app/providers.tsx +96 -96
  129. package/templates/nextblock-template/app/robots.ts +123 -123
  130. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  131. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  132. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  133. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  134. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  135. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  136. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  137. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  138. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  139. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  140. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  141. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  142. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  143. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  144. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  145. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  146. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  147. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  148. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  149. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  150. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  151. package/templates/nextblock-template/components/Header.tsx +38 -38
  152. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  153. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  154. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  155. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  156. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  157. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  158. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  159. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  160. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  161. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  162. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  163. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  164. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  165. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  166. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  167. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  168. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  169. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  170. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  171. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  172. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  173. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  174. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  175. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  176. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  177. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  178. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  179. package/templates/nextblock-template/components/form-message.tsx +32 -32
  180. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +251 -244
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  187. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  188. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  189. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  190. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  191. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  192. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  193. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  194. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  195. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  196. package/templates/nextblock-template/docs/README.md +42 -42
  197. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  198. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  199. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  200. package/templates/nextblock-template/index.d.ts +7 -7
  201. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  202. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  203. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  204. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  205. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  206. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  207. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  208. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  209. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  210. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  211. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  212. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  213. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  215. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  216. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  217. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  218. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  219. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  220. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  221. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  222. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  223. package/templates/nextblock-template/lib/search/types.ts +27 -27
  224. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +157 -140
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +384 -350
  231. package/templates/nextblock-template/lib/seo/page-document.ts +463 -412
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +37 -22
  239. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  240. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  241. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  242. package/templates/nextblock-template/lib/site-url.ts +102 -102
  243. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  244. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  245. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  246. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  247. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  248. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  249. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  250. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  251. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  252. package/templates/nextblock-template/next-env.d.ts +1 -2
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/scripts/backup.js +115 -115
  256. package/templates/nextblock-template/scripts/restore.js +385 -385
  257. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  258. package/templates/nextblock-template/tailwind.config.js +25 -25
  259. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  260. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  261. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  262. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  263. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  264. package/tsconfig.base.json +3 -3
@@ -1,412 +1,463 @@
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
+ export interface BuildPageSeoDocumentOptions {
388
+ /**
389
+ * An explicit document title to treat as the page's top-level H1.
390
+ *
391
+ * Posts in NextBlock render their title in an editorial H1 wrapper on the public
392
+ * page (`PostClientContent.tsx`) rather than storing it as a heading block inside
393
+ * the content array. Supplying `documentTitle` with `documentType: 'post'` ensures
394
+ * the audit sees that H1 instead of falsely reporting `headings-missing-h1`.
395
+ */
396
+ documentTitle?: string | null;
397
+ /**
398
+ * The kind of document being graded. When set to 'post' and a documentTitle is present,
399
+ * the title is treated as the primary H1 of the document.
400
+ */
401
+ documentType?: 'page' | 'post';
402
+ }
403
+
404
+ /**
405
+ * Builds one `SeoDocument` from a page's or post's block list.
406
+ *
407
+ * `blocks` is deliberately typed `unknown`: it comes from form state, from a
408
+ * draft row, or straight from Supabase as `Json`, and pretending at the
409
+ * signature that it is already an array of well-formed rows would only push the
410
+ * validation somewhere that has less context to do it in.
411
+ */
412
+ export function buildPageSeoDocument(
413
+ blocks: unknown,
414
+ options?: BuildPageSeoDocumentOptions
415
+ ): SeoDocument {
416
+ const isPostWithTitle =
417
+ options?.documentType === 'post' &&
418
+ typeof options?.documentTitle === 'string' &&
419
+ options.documentTitle.trim() !== '';
420
+
421
+ if (!Array.isArray(blocks) || blocks.length === 0) {
422
+ if (isPostWithTitle) {
423
+ const titleText = options!.documentTitle!.trim();
424
+ const titleWords = tokenizeWords(titleText);
425
+ return {
426
+ headings: [{ level: 1, order: 0, text: titleText }],
427
+ images: [],
428
+ links: [],
429
+ text: titleText,
430
+ words: titleWords,
431
+ };
432
+ }
433
+ return emptySeoDocument();
434
+ }
435
+
436
+ const draft = createDraft();
437
+ for (const block of blocks) {
438
+ collectBlock(block, draft, 0);
439
+ }
440
+
441
+ if (isPostWithTitle) {
442
+ const titleText = options!.documentTitle!.trim();
443
+ const titleWords = tokenizeWords(titleText);
444
+ draft.headings.unshift({
445
+ level: 1,
446
+ order: 0,
447
+ text: titleText,
448
+ });
449
+ for (let i = 1; i < draft.headings.length; i++) {
450
+ draft.headings[i].order = i;
451
+ }
452
+ draft.textParts.unshift(titleText);
453
+ draft.words.unshift(...titleWords);
454
+ }
455
+
456
+ return {
457
+ headings: draft.headings,
458
+ images: draft.images,
459
+ links: draft.links,
460
+ text: draft.textParts.join(' '),
461
+ words: draft.words,
462
+ };
463
+ }