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,749 +1,749 @@
1
- 'use client';
2
-
3
- /**
4
- * The live SEO audit that sits beside the Tiptap canvas.
5
- *
6
- * Three constraints shaped everything in this file.
7
- *
8
- * First, **the audit is deterministic and local**. Every number on screen comes
9
- * from `@nextblock-cms/utils/seo`, a pure dependency-free engine that runs in
10
- * the browser. No model is consulted to produce a score, which is what makes it
11
- * safe to re-run while someone is typing: it costs nothing, it cannot fail, and
12
- * it cannot disagree with itself between two runs over the same text. The only
13
- * network call in this component is the explicit, user-initiated "Fix with
14
- * Cortex AI" click.
15
- *
16
- * Second, **typing must never stutter**. `NotionEditor` calls `useEditor`
17
- * without `shouldRerenderOnTransaction`, so Tiptap does not re-render this
18
- * subtree on keystrokes; what does change on every keystroke is the `content`
19
- * prop, arriving through the block editor's own state. Auditing on each of those
20
- * would parse the whole document per character. The recompute is therefore on a
21
- * trailing 300 ms debounce, and documents past `SEO_LIVE_AUDIT_MAX_CHARACTERS`
22
- * are not audited live at all — a 400k-character document is pathological, and
23
- * freezing the tab to grade it would be a worse outcome than declining to.
24
- *
25
- * Third, **the panel knows nothing about how it is mounted**. It receives the
26
- * content string, an optional editor instance and the Cortex AI activation flag
27
- * as props, because `libs/editor` cannot import application code and the CMS
28
- * cannot reach into the editor's internals. The editor arrives via the
29
- * `onEditorReady` prop added to `NotionEditor` rather than through the legacy
30
- * `window.__nextblockEditor` global, which races whenever two editors mount.
31
- *
32
- * Note on metadata: `metaTitle` and `metaDescription` are forwarded to the
33
- * engine exactly as received, because the engine distinguishes `undefined`
34
- * ("this caller is not auditing metadata") from `null` or `''` ("the field
35
- * exists and is blank", which is a finding). Defaulting them here would invent
36
- * two failures every caller that only edits body copy is powerless to fix.
37
- *
38
- * Fourth, added after the first release: **the panel has a scope, and it says so
39
- * out loud**. The original version was only ever mounted inside a rich-text
40
- * block, where it graded one paragraph as though it were the whole page and
41
- * reported "no H1" and "fewer than 300 words" — neither of which is a property a
42
- * single block can have. The engine now takes a `scope`, and this component
43
- * takes two mutually exclusive sources for the text it grades: `content`, one
44
- * block's stored string, or `document`, a `SeoDocument` the caller has already
45
- * assembled from every block on the page. Whichever arrives is passed straight
46
- * through with the scope, and the header names the scope so a block score can
47
- * never be mistaken for the page score.
48
- */
49
-
50
- import * as React from 'react';
51
-
52
- import { Input, Label } from '@nextblock-cms/ui';
53
- import { cn } from '@nextblock-cms/utils';
54
- import {
55
- auditSeo,
56
- buildSeoDocument,
57
- KEYWORD_DENSITY_MAXIMUM,
58
- KEYWORD_DENSITY_MINIMUM,
59
- type SeoAuditResult,
60
- type SeoAuditScope,
61
- type SeoDocument,
62
- type SeoIssue,
63
- } from '@nextblock-cms/utils/seo';
64
- import { Check, FileWarning, Loader2, X } from 'lucide-react';
65
- import { toast } from 'sonner';
66
-
67
- import { buildCortexAiRequestHeaders } from '../../lib/cortex-ai/sandbox-headers';
68
- import {
69
- buildSeoFixContext,
70
- buildSeoFixPrompt,
71
- type SeoFixInsertionMode,
72
- } from '../../lib/seo/fix-prompts';
73
- import { SeoIssueList } from './SeoIssueList';
74
- import { SeoScoreDial } from './SeoScoreDial';
75
-
76
- /**
77
- * How long to wait after the last keystroke before re-grading the document.
78
- *
79
- * 300 ms is the usual "the author paused" threshold: long enough that a normal
80
- * typing burst produces one audit rather than forty, short enough that the panel
81
- * still feels like it is watching the document rather than reporting on it.
82
- */
83
- export const SEO_LIVE_AUDIT_DEBOUNCE_MS = 300;
84
-
85
- /**
86
- * Above this many characters the live audit is skipped entirely.
87
- *
88
- * The engine is linear in document length, but "linear" over 400k characters is
89
- * still tens of milliseconds of main-thread work on every pause, and the
90
- * documents that reach this size are pasted archives rather than pages anyone is
91
- * optimising for search. Declining, visibly, beats freezing the tab.
92
- */
93
- export const SEO_LIVE_AUDIT_MAX_CHARACTERS = 400_000;
94
-
95
- /** Matches the abort budget the editor's own Cortex AI prompt bar uses. */
96
- const SEO_FIX_TIMEOUT_MS = 150_000;
97
-
98
- /**
99
- * The slice of Tiptap's `Editor` this panel actually touches.
100
- *
101
- * Declared structurally rather than imported so the CMS does not take a hard
102
- * dependency on `@tiptap/core` for the sake of one optional prop, and so the
103
- * panel can be handed a stub in any future test. A real `Editor` satisfies this
104
- * shape, so `onEditorReady`'s value can be passed straight in.
105
- */
106
- export interface TiptapEditorLike {
107
- chain(): TiptapChainLike;
108
- commands: { setContent(content: string): boolean };
109
- getText(): string;
110
- isEmpty: boolean;
111
- off(event: string, handler: () => void): unknown;
112
- on(event: string, handler: () => void): unknown;
113
- state: {
114
- doc: {
115
- content: { size: number };
116
- textBetween(from: number, to: number, separator?: string): string;
117
- };
118
- selection: { empty: boolean; from: number; to: number };
119
- };
120
- }
121
-
122
- /** The fluent command subset used by the three insertion branches below. */
123
- export interface TiptapChainLike {
124
- focus(): TiptapChainLike;
125
- insertContentAt(
126
- position: number | { from: number; to: number },
127
- content: string
128
- ): TiptapChainLike;
129
- run(): boolean;
130
- }
131
-
132
- export interface SeoAuditPanelProps {
133
- className?: string;
134
- /**
135
- * One block's stored content. Not necessarily HTML — a text block may hold a
136
- * JSON-stringified Tiptap document instead — which is why it is handed to
137
- * `buildSeoDocument` raw: that function detects and handles both.
138
- *
139
- * Ignored when `document` is supplied, because a caller that has already
140
- * assembled the whole page cannot express it as a single string.
141
- */
142
- content?: string | null;
143
- /**
144
- * A pre-assembled document, used by the page-level mount.
145
- *
146
- * The page audit has to merge standalone `heading` blocks, the h1-h6 nodes
147
- * inside a text block's HTML, and blocks nested in a section's columns into
148
- * one ordered document; `buildPageSeoDocument` does that walk, and the result
149
- * arrives here already normalised. Building it is the caller's job rather than
150
- * this component's so that the panel keeps knowing nothing about the block
151
- * union — see the module docblock's third constraint.
152
- *
153
- * Pass a referentially stable value (a `useMemo`): its identity is a debounce
154
- * dependency, so a fresh object on every render would restart the timer
155
- * forever and the score would never settle.
156
- */
157
- document?: SeoDocument;
158
- /**
159
- * The live editor, when the host has one. Without it the panel is a read-only
160
- * report: it can still grade the document, but it has nowhere to write a fix.
161
- * The page-level mount never has one — there is no single editor behind a
162
- * dozen blocks — which is precisely why the Fix button is tied to this prop
163
- * rather than to the Cortex AI flag alone.
164
- */
165
- editor?: TiptapEditorLike | null;
166
- /** Whether the premium Cortex AI package is activated on this install. */
167
- isCortexAiActive?: boolean;
168
- keyword?: string;
169
- metaDescription?: string | null;
170
- metaTitle?: string | null;
171
- /**
172
- * Fires with each completed audit, and with `null` while one is pending or
173
- * refused. It exists so a collapsed host can show a live summary — a score and
174
- * an issue count — without running a second, competing analysis of its own.
175
- */
176
- onAuditChange?: (audit: SeoAuditResult | null) => void;
177
- onKeywordChange?: (keyword: string) => void;
178
- /**
179
- * Which checks apply. Defaults to `'page'` so that a caller who forgets to
180
- * think about scope gets the complete, honest audit rather than a silently
181
- * narrowed one.
182
- */
183
- scope?: SeoAuditScope;
184
- }
185
-
186
- /**
187
- * The header, per scope.
188
- *
189
- * This wording is load-bearing rather than decorative: the bug that prompted the
190
- * page-level audit was an author reading a block's score as the page's score, so
191
- * both headings name their subject and the block description points at where the
192
- * page-wide checks actually live.
193
- */
194
- const SCOPE_HEADING: Record<SeoAuditScope, string> = {
195
- block: 'Block SEO analysis',
196
- page: 'Page SEO analysis',
197
- };
198
-
199
- const SCOPE_DESCRIPTION: Record<SeoAuditScope, string> = {
200
- block:
201
- 'Scored on this block alone. Page-wide checks — one H1, total word count, meta title and description — are on the page editor’s SEO panel.',
202
- page: 'Scored across every block on this page, together with the meta title and description.',
203
- };
204
-
205
- /** Bar colours for the density meter, matching the CMS pass/warn convention. */
206
- const DENSITY_STATE_STYLE: Record<'high' | 'low' | 'ok', string> = {
207
- high: 'bg-amber-500',
208
- low: 'bg-amber-500',
209
- ok: 'bg-emerald-500',
210
- };
211
-
212
- const DENSITY_STATE_LABEL: Record<'high' | 'low' | 'ok', string> = {
213
- high: 'Above the target range',
214
- low: 'Below the target range',
215
- ok: 'In the target range',
216
- };
217
-
218
- /**
219
- * Upper bound of the density meter's scale, in percent.
220
- *
221
- * The target band is 1.0%-2.5%, so a 5% scale puts it across the middle of the
222
- * track where it is legible, while still leaving room to show a genuinely
223
- * over-stuffed page as pinned near the end rather than silently clipped.
224
- */
225
- const DENSITY_SCALE_MAXIMUM = 5;
226
-
227
- export function SeoAuditPanel({
228
- className,
229
- content,
230
- // Renamed on the way in: an unqualified `document` inside a component body
231
- // shadows the global one, and a later reader would have to check which was
232
- // meant on every line that mentions it.
233
- document: providedDocument,
234
- editor,
235
- isCortexAiActive = false,
236
- keyword,
237
- metaDescription,
238
- metaTitle,
239
- onAuditChange,
240
- onKeywordChange,
241
- scope = 'page',
242
- }: SeoAuditPanelProps) {
243
- /**
244
- * The keyphrase field's id.
245
- *
246
- * Generated rather than hard-coded because both scopes can be on screen at
247
- * once — the page-level panel sits on the edit screen while a block editor
248
- * opens over it in a dialog — and two elements sharing an id would send every
249
- * click on one panel's `<Label>` to the other panel's input.
250
- */
251
- const keywordInputId = React.useId();
252
-
253
- /**
254
- * The keyphrase is controlled by the host so it can be persisted with the
255
- * content. When no handler is supplied the panel keeps its own copy instead of
256
- * rendering an input that silently refuses every keystroke — a dead field is a
257
- * bug report waiting to happen, and an ephemeral one is still useful for a
258
- * quick "what would this score for…" check.
259
- */
260
- const [internalKeyword, setInternalKeyword] = React.useState(keyword ?? '');
261
- const isKeywordControlled = typeof onKeywordChange === 'function';
262
- const activeKeyword = isKeywordControlled ? keyword ?? '' : internalKeyword;
263
-
264
- const [audit, setAudit] = React.useState<SeoAuditResult | null>(null);
265
- const [isRecomputePending, setIsRecomputePending] = React.useState(true);
266
- const [isDocumentTooLarge, setIsDocumentTooLarge] = React.useState(false);
267
-
268
- const [fixingIssueId, setFixingIssueId] = React.useState<string | null>(null);
269
- const [fixErrors, setFixErrors] = React.useState<Record<string, string>>({});
270
- const [hasSelection, setHasSelection] = React.useState(false);
271
-
272
- /**
273
- * How much text is on the table, measured before any parsing happens.
274
- *
275
- * For a raw `content` string this is the unparsed source length, which is the
276
- * only number available before `buildSeoDocument` runs and therefore the only
277
- * one that can keep a pathological paste from being parsed at all. For a
278
- * pre-assembled `document` the caller has already paid for the walk, so the
279
- * normalised text length is both available and the more honest measure.
280
- */
281
- const sourceLength = providedDocument ? providedDocument.text.length : (content ?? '').length;
282
-
283
- // The trailing debounce. Every dependency here is a value the score depends
284
- // on, so changing the keyphrase re-grades on the same 300 ms delay as typing
285
- // does rather than on a separate, faster path that could interleave results.
286
- React.useEffect(() => {
287
- if (sourceLength > SEO_LIVE_AUDIT_MAX_CHARACTERS) {
288
- setIsDocumentTooLarge(true);
289
- setIsRecomputePending(false);
290
- setAudit(null);
291
- return;
292
- }
293
-
294
- setIsDocumentTooLarge(false);
295
- setIsRecomputePending(true);
296
-
297
- const timeoutId = window.setTimeout(() => {
298
- setAudit(
299
- auditSeo({
300
- // A supplied document wins over `content`: the page-level caller has
301
- // merged many blocks into one document and has no single string left
302
- // to hand over, so there is nothing sensible to fall back to.
303
- document: providedDocument ?? buildSeoDocument(content ?? ''),
304
- keyword: activeKeyword,
305
- metaDescription,
306
- metaTitle,
307
- scope,
308
- })
309
- );
310
- setIsRecomputePending(false);
311
- }, SEO_LIVE_AUDIT_DEBOUNCE_MS);
312
-
313
- return () => window.clearTimeout(timeoutId);
314
- }, [activeKeyword, content, metaDescription, metaTitle, providedDocument, scope, sourceLength]);
315
-
316
- /**
317
- * Republish each result to the host.
318
- *
319
- * Reporting from an effect rather than from inside the debounce callback keeps
320
- * the "who owns this state" story simple — the panel owns the audit, the host
321
- * only observes it — and it means a host that collapses the panel still sees
322
- * every recomputation, including the `null` the panel publishes while one is
323
- * pending or when the document was too large to grade.
324
- */
325
- React.useEffect(() => {
326
- onAuditChange?.(audit);
327
- }, [audit, onAuditChange]);
328
-
329
- /**
330
- * Track whether the author has text selected.
331
- *
332
- * Subscribing to editor events is the only way to know: `useEditor` was called
333
- * without `shouldRerenderOnTransaction`, so nothing in this subtree re-renders
334
- * when the selection moves. It is worth knowing because it decides where a fix
335
- * lands — with a selection the rewrite replaces it, without one the rewrite is
336
- * appended — and telling the author that before they click is the difference
337
- * between a fix and a surprise.
338
- */
339
- React.useEffect(() => {
340
- if (!editor) {
341
- setHasSelection(false);
342
- return;
343
- }
344
-
345
- const sync = () => setHasSelection(!editor.state.selection.empty);
346
-
347
- sync();
348
- editor.on('selectionUpdate', sync);
349
- editor.on('transaction', sync);
350
-
351
- return () => {
352
- editor.off('selectionUpdate', sync);
353
- editor.off('transaction', sync);
354
- };
355
- }, [editor]);
356
-
357
- const canOfferFixes = isCortexAiActive && Boolean(editor);
358
-
359
- const handleFix = React.useCallback(
360
- async (issue: SeoIssue) => {
361
- if (!editor || fixingIssueId !== null) {
362
- return;
363
- }
364
-
365
- setFixingIssueId(issue.id);
366
- setFixErrors((previous) => {
367
- const next = { ...previous };
368
- delete next[issue.id];
369
- return next;
370
- });
371
-
372
- // Everything about the target is captured before the await: the document
373
- // can change while the request is in flight, and applying the answer to a
374
- // position derived from the post-response state would drop the fragment
375
- // somewhere the author never asked for.
376
- const abortController = new AbortController();
377
- const timeoutId = window.setTimeout(() => abortController.abort(), SEO_FIX_TIMEOUT_MS);
378
- const wasEditorEmpty = editor.isEmpty;
379
- const selectionBefore = editor.state.selection;
380
- const selectionHadRange = !selectionBefore.empty;
381
- const selectionFrom = selectionBefore.from;
382
- const selectionTo = selectionBefore.to;
383
- const selectedText = selectionHadRange
384
- ? editor.state.doc.textBetween(selectionFrom, selectionTo, ' ').trim()
385
- : '';
386
- const insertionMode: SeoFixInsertionMode = wasEditorEmpty
387
- ? 'replace-empty-document'
388
- : selectionHadRange
389
- ? 'replace-selection'
390
- : 'append-to-end';
391
-
392
- try {
393
- // Sandbox installs have no server-side Cortex AI credentials, so the
394
- // browser forwards the visitor's own OpenRouter key from localStorage.
395
- // `NotionEditor.handleAiGenerate` still inlines this — it cannot import
396
- // app code — but every call site inside the CMS goes through the shared
397
- // builder, which is the whole reason that module exists: a header name
398
- // or storage key that changes in one copy and not the other fails
399
- // silently, authenticating one AI button and not the next.
400
- const headers = buildCortexAiRequestHeaders();
401
-
402
- const context = buildSeoFixContext({
403
- documentText: editor.getText().trim(),
404
- headingOutline: audit?.headings.map((heading) => `H${heading.level} ${heading.text}`),
405
- insertionMode,
406
- selectedText,
407
- wordCount: audit?.readability.wordCount ?? null,
408
- });
409
- const prompt = buildSeoFixPrompt({
410
- issue,
411
- keyword: activeKeyword,
412
- wordCount: audit?.readability.wordCount ?? null,
413
- });
414
-
415
- // The body carries `context` and `prompt` and nothing else: the route
416
- // validates with a `z.strictObject`, so one stray key is a flat 400.
417
- const response = await fetch('/api/ai/generate-blocks', {
418
- body: JSON.stringify({ context, prompt }),
419
- headers,
420
- method: 'POST',
421
- signal: abortController.signal,
422
- });
423
- const payload = await response.json();
424
-
425
- if (!response.ok) {
426
- throw new Error(payload?.error || 'Cortex AI could not generate content.');
427
- }
428
-
429
- if (!payload || typeof payload.html !== 'string' || !payload.html.trim()) {
430
- throw new Error('Cortex AI returned an invalid HTML fragment.');
431
- }
432
-
433
- if (wasEditorEmpty || editor.isEmpty) {
434
- editor.commands.setContent(payload.html);
435
- } else if (selectionHadRange) {
436
- // The document may have shrunk while the request was outstanding, so
437
- // both ends are clamped: ProseMirror throws on an out-of-range
438
- // position, which would lose the generated fragment entirely.
439
- const docEnd = editor.state.doc.content.size;
440
- const from = Math.min(selectionFrom, docEnd);
441
- const to = Math.min(selectionTo, docEnd);
442
-
443
- editor.chain().focus().insertContentAt({ from, to }, payload.html).run();
444
- } else {
445
- editor.chain().focus().insertContentAt(editor.state.doc.content.size, payload.html).run();
446
- }
447
-
448
- toast.success('Cortex AI applied a fix. Review it before publishing.');
449
- } catch (error) {
450
- const message =
451
- error instanceof DOMException && error.name === 'AbortError'
452
- ? 'Cortex AI took too long to respond. Please try again.'
453
- : error instanceof Error
454
- ? error.message
455
- : 'Cortex AI could not generate content.';
456
-
457
- setFixErrors((previous) => ({ ...previous, [issue.id]: message }));
458
- } finally {
459
- window.clearTimeout(timeoutId);
460
- setFixingIssueId(null);
461
- }
462
- },
463
- [activeKeyword, audit, editor, fixingIssueId]
464
- );
465
-
466
- const keywordStats = audit?.keyword ?? null;
467
- const densityState: 'high' | 'low' | 'ok' = !keywordStats
468
- ? 'low'
469
- : keywordStats.density < KEYWORD_DENSITY_MINIMUM
470
- ? 'low'
471
- : keywordStats.density > KEYWORD_DENSITY_MAXIMUM
472
- ? 'high'
473
- : 'ok';
474
-
475
- return (
476
- <div className={cn('flex flex-col gap-4 p-4 text-sm', className)}>
477
- <header className="space-y-1">
478
- <h3 className="text-sm font-semibold">{SCOPE_HEADING[scope]}</h3>
479
- <p className="text-xs text-muted-foreground">{SCOPE_DESCRIPTION[scope]}</p>
480
- <p className="text-xs text-muted-foreground">
481
- Updates automatically as you write. Nothing here is sent anywhere until you ask Cortex
482
- AI to fix something.
483
- </p>
484
- </header>
485
-
486
- <div className="space-y-2">
487
- <Label className="text-xs" htmlFor={keywordInputId}>
488
- Focus keyphrase
489
- </Label>
490
- <Input
491
- className="h-8 text-sm"
492
- id={keywordInputId}
493
- onChange={(event) => {
494
- const value = event.target.value;
495
- if (isKeywordControlled) {
496
- onKeywordChange?.(value);
497
- } else {
498
- setInternalKeyword(value);
499
- }
500
- }}
501
- placeholder="e.g. organic green tea"
502
- value={activeKeyword}
503
- />
504
- <p className="text-xs text-muted-foreground">
505
- The phrase this page should rank for. Leave it blank to skip the keyphrase checks.
506
- </p>
507
- </div>
508
-
509
- {/* A polite live region rather than an assertive one: the score changing
510
- while you type is information, not an interruption. */}
511
- <div aria-live="polite" className="sr-only">
512
- {audit ? `SEO score ${audit.score} out of 100.` : 'Analysing the document.'}
513
- </div>
514
-
515
- {isDocumentTooLarge ? (
516
- <div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
517
- <FileWarning
518
- aria-hidden="true"
519
- className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400"
520
- />
521
- <p>
522
- This document is too large for the live audit ({sourceLength.toLocaleString()}{' '}
523
- characters). Grading it on every pause would freeze the editor, so the analysis is
524
- paused until there is less text to read.
525
- </p>
526
- </div>
527
- ) : !audit ? (
528
- <div className="flex items-center gap-2 rounded-md border bg-muted/40 p-3 text-xs text-muted-foreground">
529
- <Loader2 aria-hidden="true" className="h-4 w-4 animate-spin" />
530
- Analysing the document…
531
- </div>
532
- ) : (
533
- <>
534
- <SeoScoreDial band={audit.scoreBand} score={audit.score} scope={scope} />
535
-
536
- {isRecomputePending && (
537
- <p className="text-xs text-muted-foreground">Recalculating…</p>
538
- )}
539
-
540
- {audit.checks.length > 0 && (
541
- <section className="space-y-2">
542
- <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
543
- Checks
544
- </h4>
545
- <ul className="space-y-1">
546
- {audit.checks.map((check) => (
547
- <li key={check.id} className="flex items-start gap-2 text-xs">
548
- {check.passed ? (
549
- <Check
550
- aria-hidden="true"
551
- className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400"
552
- />
553
- ) : (
554
- <X
555
- aria-hidden="true"
556
- className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600 dark:text-red-400"
557
- />
558
- )}
559
- <span className={cn('flex-1', check.passed ? '' : 'font-medium')}>
560
- {/* Spelled out for screen readers, because the tick and
561
- the cross are the only visual difference between a
562
- passing and a failing row. */}
563
- <span className="sr-only">{check.passed ? 'Passed: ' : 'Failed: '}</span>
564
- {check.label}
565
- </span>
566
- <span className="shrink-0 tabular-nums text-muted-foreground">
567
- {check.weight} pts
568
- </span>
569
- </li>
570
- ))}
571
- </ul>
572
- </section>
573
- )}
574
-
575
- <SeoIssueList
576
- busyIssueId={fixingIssueId}
577
- errors={fixErrors}
578
- issues={audit.issues}
579
- onFix={canOfferFixes ? (issue) => void handleFix(issue) : undefined}
580
- />
581
-
582
- {canOfferFixes && (
583
- <p className="text-xs text-muted-foreground">
584
- {hasSelection
585
- ? 'A fix will rewrite the text you have selected.'
586
- : 'Nothing is selected, so a fix will be added at the end of the block. Select a passage first to rewrite it in place.'}
587
- </p>
588
- )}
589
-
590
- <section className="space-y-2">
591
- <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
592
- Readability
593
- </h4>
594
- <dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
595
- <dt className="text-muted-foreground">Flesch reading ease</dt>
596
- <dd className="text-right font-medium tabular-nums">
597
- {audit.readability.fleschReadingEase}
598
- </dd>
599
- <dt className="text-muted-foreground">Grade</dt>
600
- <dd className="text-right font-medium">{audit.readability.grade}</dd>
601
- <dt className="text-muted-foreground">Average sentence length</dt>
602
- <dd className="text-right font-medium tabular-nums">
603
- {audit.readability.averageSentenceLength} words
604
- </dd>
605
- <dt className="text-muted-foreground">Syllables per word</dt>
606
- <dd className="text-right font-medium tabular-nums">
607
- {audit.readability.averageSyllablesPerWord}
608
- </dd>
609
- <dt className="text-muted-foreground">Sentences</dt>
610
- <dd className="text-right font-medium tabular-nums">
611
- {audit.readability.sentenceCount}
612
- </dd>
613
- <dt className="text-muted-foreground">Words</dt>
614
- <dd className="text-right font-medium tabular-nums">
615
- {audit.readability.wordCount}
616
- </dd>
617
- <dt className="text-muted-foreground">Syllables</dt>
618
- <dd className="text-right font-medium tabular-nums">
619
- {audit.readability.syllableCount}
620
- </dd>
621
- </dl>
622
- </section>
623
-
624
- <section className="space-y-2">
625
- <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
626
- Keyphrase
627
- </h4>
628
-
629
- {!keywordStats ? (
630
- <p className="text-xs text-muted-foreground">
631
- Set a focus keyphrase above to see how often and where it appears.
632
- </p>
633
- ) : (
634
- <div className="space-y-3">
635
- <div className="space-y-1.5">
636
- <div className="flex items-baseline justify-between text-xs">
637
- <span className="text-muted-foreground">Density</span>
638
- <span className="font-medium tabular-nums">
639
- {keywordStats.density}% &middot; {keywordStats.count}{' '}
640
- {keywordStats.count === 1 ? 'match' : 'matches'}
641
- </span>
642
- </div>
643
-
644
- {/*
645
- Hand-drawn rather than the shared `Progress` primitive: that
646
- component ignores its `max` and positions the indicator from
647
- `value` as a raw percentage, so a 0-5% scale would render at
648
- a twentieth of its true width. Drawing it here also lets the
649
- 1.0%-2.5% target band be shaded into the track, which is the
650
- one thing a bare bar cannot tell you.
651
- */}
652
- <div
653
- aria-label="Keyphrase density"
654
- aria-valuemax={DENSITY_SCALE_MAXIMUM}
655
- aria-valuemin={0}
656
- aria-valuenow={keywordStats.density}
657
- aria-valuetext={`${keywordStats.density} percent. ${DENSITY_STATE_LABEL[densityState]} of ${KEYWORD_DENSITY_MINIMUM} to ${KEYWORD_DENSITY_MAXIMUM} percent.`}
658
- className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
659
- role="meter"
660
- >
661
- <div
662
- aria-hidden="true"
663
- className="absolute inset-y-0 bg-emerald-500/20"
664
- style={{
665
- left: `${(KEYWORD_DENSITY_MINIMUM / DENSITY_SCALE_MAXIMUM) * 100}%`,
666
- width: `${((KEYWORD_DENSITY_MAXIMUM - KEYWORD_DENSITY_MINIMUM) / DENSITY_SCALE_MAXIMUM) * 100}%`,
667
- }}
668
- />
669
- <div
670
- aria-hidden="true"
671
- className={cn(
672
- 'absolute inset-y-0 left-0 rounded-full transition-[width]',
673
- DENSITY_STATE_STYLE[densityState]
674
- )}
675
- style={{
676
- width: `${Math.min(100, (keywordStats.density / DENSITY_SCALE_MAXIMUM) * 100)}%`,
677
- }}
678
- />
679
- </div>
680
-
681
- <p className="text-xs text-muted-foreground">
682
- {DENSITY_STATE_LABEL[densityState]} — aim for {KEYWORD_DENSITY_MINIMUM}% to{' '}
683
- {KEYWORD_DENSITY_MAXIMUM}%.
684
- </p>
685
- </div>
686
-
687
- <ul className="space-y-1 text-xs">
688
- <KeywordPlacement label="Appears in the H1" present={keywordStats.inHeading1} />
689
- <KeywordPlacement
690
- label="Appears in a subheading"
691
- present={keywordStats.inSubheadings}
692
- />
693
- <KeywordPlacement
694
- label="Appears in the first 100 words"
695
- present={keywordStats.inFirst100Words}
696
- />
697
- </ul>
698
- </div>
699
- )}
700
- </section>
701
-
702
- {/*
703
- Two different reasons for the same missing button, and they need
704
- different sentences. At block scope the editor is merely late — it is
705
- loaded with `ssr: false` behind a fetch — so the button really will
706
- appear. At page scope there is no editor and never will be: a page is
707
- a dozen blocks and a fix has to land in one of them, so the honest
708
- instruction is to open that block. Rendering a Fix button here that
709
- had nowhere to write would be worse than rendering none.
710
- */}
711
- {isCortexAiActive && !editor && (
712
- <p className="text-xs text-muted-foreground">
713
- {scope === 'page'
714
- ? 'One-click fixes live in the block editors. Open the block a finding refers to and use its own SEO panel to have Cortex AI rewrite it.'
715
- : 'One-click fixes appear once the editor has finished loading.'}
716
- </p>
717
- )}
718
- </>
719
- )}
720
- </div>
721
- );
722
- }
723
-
724
- /**
725
- * One yes/no keyphrase placement row.
726
- *
727
- * The tick or cross is paired with an off-screen word so the state is never
728
- * conveyed by the glyph and its colour alone.
729
- */
730
- function KeywordPlacement({ label, present }: { label: string; present: boolean }) {
731
- return (
732
- <li className="flex items-center gap-2">
733
- {present ? (
734
- <Check
735
- aria-hidden="true"
736
- className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400"
737
- />
738
- ) : (
739
- <X aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
740
- )}
741
- <span>
742
- <span className="sr-only">{present ? 'Yes: ' : 'No: '}</span>
743
- {label}
744
- </span>
745
- </li>
746
- );
747
- }
748
-
749
- export default SeoAuditPanel;
1
+ 'use client';
2
+
3
+ /**
4
+ * The live SEO audit that sits beside the Tiptap canvas.
5
+ *
6
+ * Three constraints shaped everything in this file.
7
+ *
8
+ * First, **the audit is deterministic and local**. Every number on screen comes
9
+ * from `@nextblock-cms/utils/seo`, a pure dependency-free engine that runs in
10
+ * the browser. No model is consulted to produce a score, which is what makes it
11
+ * safe to re-run while someone is typing: it costs nothing, it cannot fail, and
12
+ * it cannot disagree with itself between two runs over the same text. The only
13
+ * network call in this component is the explicit, user-initiated "Fix with
14
+ * Cortex AI" click.
15
+ *
16
+ * Second, **typing must never stutter**. `NotionEditor` calls `useEditor`
17
+ * without `shouldRerenderOnTransaction`, so Tiptap does not re-render this
18
+ * subtree on keystrokes; what does change on every keystroke is the `content`
19
+ * prop, arriving through the block editor's own state. Auditing on each of those
20
+ * would parse the whole document per character. The recompute is therefore on a
21
+ * trailing 300 ms debounce, and documents past `SEO_LIVE_AUDIT_MAX_CHARACTERS`
22
+ * are not audited live at all — a 400k-character document is pathological, and
23
+ * freezing the tab to grade it would be a worse outcome than declining to.
24
+ *
25
+ * Third, **the panel knows nothing about how it is mounted**. It receives the
26
+ * content string, an optional editor instance and the Cortex AI activation flag
27
+ * as props, because `libs/editor` cannot import application code and the CMS
28
+ * cannot reach into the editor's internals. The editor arrives via the
29
+ * `onEditorReady` prop added to `NotionEditor` rather than through the legacy
30
+ * `window.__nextblockEditor` global, which races whenever two editors mount.
31
+ *
32
+ * Note on metadata: `metaTitle` and `metaDescription` are forwarded to the
33
+ * engine exactly as received, because the engine distinguishes `undefined`
34
+ * ("this caller is not auditing metadata") from `null` or `''` ("the field
35
+ * exists and is blank", which is a finding). Defaulting them here would invent
36
+ * two failures every caller that only edits body copy is powerless to fix.
37
+ *
38
+ * Fourth, added after the first release: **the panel has a scope, and it says so
39
+ * out loud**. The original version was only ever mounted inside a rich-text
40
+ * block, where it graded one paragraph as though it were the whole page and
41
+ * reported "no H1" and "fewer than 300 words" — neither of which is a property a
42
+ * single block can have. The engine now takes a `scope`, and this component
43
+ * takes two mutually exclusive sources for the text it grades: `content`, one
44
+ * block's stored string, or `document`, a `SeoDocument` the caller has already
45
+ * assembled from every block on the page. Whichever arrives is passed straight
46
+ * through with the scope, and the header names the scope so a block score can
47
+ * never be mistaken for the page score.
48
+ */
49
+
50
+ import * as React from 'react';
51
+
52
+ import { Input, Label } from '@nextblock-cms/ui';
53
+ import { cn } from '@nextblock-cms/utils';
54
+ import {
55
+ auditSeo,
56
+ buildSeoDocument,
57
+ KEYWORD_DENSITY_MAXIMUM,
58
+ KEYWORD_DENSITY_MINIMUM,
59
+ type SeoAuditResult,
60
+ type SeoAuditScope,
61
+ type SeoDocument,
62
+ type SeoIssue,
63
+ } from '@nextblock-cms/utils/seo';
64
+ import { Check, FileWarning, Loader2, X } from 'lucide-react';
65
+ import { toast } from 'sonner';
66
+
67
+ import { buildCortexAiRequestHeaders } from '../../lib/cortex-ai/sandbox-headers';
68
+ import {
69
+ buildSeoFixContext,
70
+ buildSeoFixPrompt,
71
+ type SeoFixInsertionMode,
72
+ } from '../../lib/seo/fix-prompts';
73
+ import { SeoIssueList } from './SeoIssueList';
74
+ import { SeoScoreDial } from './SeoScoreDial';
75
+
76
+ /**
77
+ * How long to wait after the last keystroke before re-grading the document.
78
+ *
79
+ * 300 ms is the usual "the author paused" threshold: long enough that a normal
80
+ * typing burst produces one audit rather than forty, short enough that the panel
81
+ * still feels like it is watching the document rather than reporting on it.
82
+ */
83
+ export const SEO_LIVE_AUDIT_DEBOUNCE_MS = 300;
84
+
85
+ /**
86
+ * Above this many characters the live audit is skipped entirely.
87
+ *
88
+ * The engine is linear in document length, but "linear" over 400k characters is
89
+ * still tens of milliseconds of main-thread work on every pause, and the
90
+ * documents that reach this size are pasted archives rather than pages anyone is
91
+ * optimising for search. Declining, visibly, beats freezing the tab.
92
+ */
93
+ export const SEO_LIVE_AUDIT_MAX_CHARACTERS = 400_000;
94
+
95
+ /** Matches the abort budget the editor's own Cortex AI prompt bar uses. */
96
+ const SEO_FIX_TIMEOUT_MS = 150_000;
97
+
98
+ /**
99
+ * The slice of Tiptap's `Editor` this panel actually touches.
100
+ *
101
+ * Declared structurally rather than imported so the CMS does not take a hard
102
+ * dependency on `@tiptap/core` for the sake of one optional prop, and so the
103
+ * panel can be handed a stub in any future test. A real `Editor` satisfies this
104
+ * shape, so `onEditorReady`'s value can be passed straight in.
105
+ */
106
+ export interface TiptapEditorLike {
107
+ chain(): TiptapChainLike;
108
+ commands: { setContent(content: string): boolean };
109
+ getText(): string;
110
+ isEmpty: boolean;
111
+ off(event: string, handler: () => void): unknown;
112
+ on(event: string, handler: () => void): unknown;
113
+ state: {
114
+ doc: {
115
+ content: { size: number };
116
+ textBetween(from: number, to: number, separator?: string): string;
117
+ };
118
+ selection: { empty: boolean; from: number; to: number };
119
+ };
120
+ }
121
+
122
+ /** The fluent command subset used by the three insertion branches below. */
123
+ export interface TiptapChainLike {
124
+ focus(): TiptapChainLike;
125
+ insertContentAt(
126
+ position: number | { from: number; to: number },
127
+ content: string
128
+ ): TiptapChainLike;
129
+ run(): boolean;
130
+ }
131
+
132
+ export interface SeoAuditPanelProps {
133
+ className?: string;
134
+ /**
135
+ * One block's stored content. Not necessarily HTML — a text block may hold a
136
+ * JSON-stringified Tiptap document instead — which is why it is handed to
137
+ * `buildSeoDocument` raw: that function detects and handles both.
138
+ *
139
+ * Ignored when `document` is supplied, because a caller that has already
140
+ * assembled the whole page cannot express it as a single string.
141
+ */
142
+ content?: string | null;
143
+ /**
144
+ * A pre-assembled document, used by the page-level mount.
145
+ *
146
+ * The page audit has to merge standalone `heading` blocks, the h1-h6 nodes
147
+ * inside a text block's HTML, and blocks nested in a section's columns into
148
+ * one ordered document; `buildPageSeoDocument` does that walk, and the result
149
+ * arrives here already normalised. Building it is the caller's job rather than
150
+ * this component's so that the panel keeps knowing nothing about the block
151
+ * union — see the module docblock's third constraint.
152
+ *
153
+ * Pass a referentially stable value (a `useMemo`): its identity is a debounce
154
+ * dependency, so a fresh object on every render would restart the timer
155
+ * forever and the score would never settle.
156
+ */
157
+ document?: SeoDocument;
158
+ /**
159
+ * The live editor, when the host has one. Without it the panel is a read-only
160
+ * report: it can still grade the document, but it has nowhere to write a fix.
161
+ * The page-level mount never has one — there is no single editor behind a
162
+ * dozen blocks — which is precisely why the Fix button is tied to this prop
163
+ * rather than to the Cortex AI flag alone.
164
+ */
165
+ editor?: TiptapEditorLike | null;
166
+ /** Whether the premium Cortex AI package is activated on this install. */
167
+ isCortexAiActive?: boolean;
168
+ keyword?: string;
169
+ metaDescription?: string | null;
170
+ metaTitle?: string | null;
171
+ /**
172
+ * Fires with each completed audit, and with `null` while one is pending or
173
+ * refused. It exists so a collapsed host can show a live summary — a score and
174
+ * an issue count — without running a second, competing analysis of its own.
175
+ */
176
+ onAuditChange?: (audit: SeoAuditResult | null) => void;
177
+ onKeywordChange?: (keyword: string) => void;
178
+ /**
179
+ * Which checks apply. Defaults to `'page'` so that a caller who forgets to
180
+ * think about scope gets the complete, honest audit rather than a silently
181
+ * narrowed one.
182
+ */
183
+ scope?: SeoAuditScope;
184
+ }
185
+
186
+ /**
187
+ * The header, per scope.
188
+ *
189
+ * This wording is load-bearing rather than decorative: the bug that prompted the
190
+ * page-level audit was an author reading a block's score as the page's score, so
191
+ * both headings name their subject and the block description points at where the
192
+ * page-wide checks actually live.
193
+ */
194
+ const SCOPE_HEADING: Record<SeoAuditScope, string> = {
195
+ block: 'Block SEO analysis',
196
+ page: 'Page SEO analysis',
197
+ };
198
+
199
+ const SCOPE_DESCRIPTION: Record<SeoAuditScope, string> = {
200
+ block:
201
+ 'Scored on this block alone. Page-wide checks — one H1, total word count, meta title and description — are on the page editor’s SEO panel.',
202
+ page: 'Scored across every block on this page, together with the meta title and description.',
203
+ };
204
+
205
+ /** Bar colours for the density meter, matching the CMS pass/warn convention. */
206
+ const DENSITY_STATE_STYLE: Record<'high' | 'low' | 'ok', string> = {
207
+ high: 'bg-amber-500',
208
+ low: 'bg-amber-500',
209
+ ok: 'bg-emerald-500',
210
+ };
211
+
212
+ const DENSITY_STATE_LABEL: Record<'high' | 'low' | 'ok', string> = {
213
+ high: 'Above the target range',
214
+ low: 'Below the target range',
215
+ ok: 'In the target range',
216
+ };
217
+
218
+ /**
219
+ * Upper bound of the density meter's scale, in percent.
220
+ *
221
+ * The target band is 1.0%-2.5%, so a 5% scale puts it across the middle of the
222
+ * track where it is legible, while still leaving room to show a genuinely
223
+ * over-stuffed page as pinned near the end rather than silently clipped.
224
+ */
225
+ const DENSITY_SCALE_MAXIMUM = 5;
226
+
227
+ export function SeoAuditPanel({
228
+ className,
229
+ content,
230
+ // Renamed on the way in: an unqualified `document` inside a component body
231
+ // shadows the global one, and a later reader would have to check which was
232
+ // meant on every line that mentions it.
233
+ document: providedDocument,
234
+ editor,
235
+ isCortexAiActive = false,
236
+ keyword,
237
+ metaDescription,
238
+ metaTitle,
239
+ onAuditChange,
240
+ onKeywordChange,
241
+ scope = 'page',
242
+ }: SeoAuditPanelProps) {
243
+ /**
244
+ * The keyphrase field's id.
245
+ *
246
+ * Generated rather than hard-coded because both scopes can be on screen at
247
+ * once — the page-level panel sits on the edit screen while a block editor
248
+ * opens over it in a dialog — and two elements sharing an id would send every
249
+ * click on one panel's `<Label>` to the other panel's input.
250
+ */
251
+ const keywordInputId = React.useId();
252
+
253
+ /**
254
+ * The keyphrase is controlled by the host so it can be persisted with the
255
+ * content. When no handler is supplied the panel keeps its own copy instead of
256
+ * rendering an input that silently refuses every keystroke — a dead field is a
257
+ * bug report waiting to happen, and an ephemeral one is still useful for a
258
+ * quick "what would this score for…" check.
259
+ */
260
+ const [internalKeyword, setInternalKeyword] = React.useState(keyword ?? '');
261
+ const isKeywordControlled = typeof onKeywordChange === 'function';
262
+ const activeKeyword = isKeywordControlled ? keyword ?? '' : internalKeyword;
263
+
264
+ const [audit, setAudit] = React.useState<SeoAuditResult | null>(null);
265
+ const [isRecomputePending, setIsRecomputePending] = React.useState(true);
266
+ const [isDocumentTooLarge, setIsDocumentTooLarge] = React.useState(false);
267
+
268
+ const [fixingIssueId, setFixingIssueId] = React.useState<string | null>(null);
269
+ const [fixErrors, setFixErrors] = React.useState<Record<string, string>>({});
270
+ const [hasSelection, setHasSelection] = React.useState(false);
271
+
272
+ /**
273
+ * How much text is on the table, measured before any parsing happens.
274
+ *
275
+ * For a raw `content` string this is the unparsed source length, which is the
276
+ * only number available before `buildSeoDocument` runs and therefore the only
277
+ * one that can keep a pathological paste from being parsed at all. For a
278
+ * pre-assembled `document` the caller has already paid for the walk, so the
279
+ * normalised text length is both available and the more honest measure.
280
+ */
281
+ const sourceLength = providedDocument ? providedDocument.text.length : (content ?? '').length;
282
+
283
+ // The trailing debounce. Every dependency here is a value the score depends
284
+ // on, so changing the keyphrase re-grades on the same 300 ms delay as typing
285
+ // does rather than on a separate, faster path that could interleave results.
286
+ React.useEffect(() => {
287
+ if (sourceLength > SEO_LIVE_AUDIT_MAX_CHARACTERS) {
288
+ setIsDocumentTooLarge(true);
289
+ setIsRecomputePending(false);
290
+ setAudit(null);
291
+ return;
292
+ }
293
+
294
+ setIsDocumentTooLarge(false);
295
+ setIsRecomputePending(true);
296
+
297
+ const timeoutId = window.setTimeout(() => {
298
+ setAudit(
299
+ auditSeo({
300
+ // A supplied document wins over `content`: the page-level caller has
301
+ // merged many blocks into one document and has no single string left
302
+ // to hand over, so there is nothing sensible to fall back to.
303
+ document: providedDocument ?? buildSeoDocument(content ?? ''),
304
+ keyword: activeKeyword,
305
+ metaDescription,
306
+ metaTitle,
307
+ scope,
308
+ })
309
+ );
310
+ setIsRecomputePending(false);
311
+ }, SEO_LIVE_AUDIT_DEBOUNCE_MS);
312
+
313
+ return () => window.clearTimeout(timeoutId);
314
+ }, [activeKeyword, content, metaDescription, metaTitle, providedDocument, scope, sourceLength]);
315
+
316
+ /**
317
+ * Republish each result to the host.
318
+ *
319
+ * Reporting from an effect rather than from inside the debounce callback keeps
320
+ * the "who owns this state" story simple — the panel owns the audit, the host
321
+ * only observes it — and it means a host that collapses the panel still sees
322
+ * every recomputation, including the `null` the panel publishes while one is
323
+ * pending or when the document was too large to grade.
324
+ */
325
+ React.useEffect(() => {
326
+ onAuditChange?.(audit);
327
+ }, [audit, onAuditChange]);
328
+
329
+ /**
330
+ * Track whether the author has text selected.
331
+ *
332
+ * Subscribing to editor events is the only way to know: `useEditor` was called
333
+ * without `shouldRerenderOnTransaction`, so nothing in this subtree re-renders
334
+ * when the selection moves. It is worth knowing because it decides where a fix
335
+ * lands — with a selection the rewrite replaces it, without one the rewrite is
336
+ * appended — and telling the author that before they click is the difference
337
+ * between a fix and a surprise.
338
+ */
339
+ React.useEffect(() => {
340
+ if (!editor) {
341
+ setHasSelection(false);
342
+ return;
343
+ }
344
+
345
+ const sync = () => setHasSelection(!editor.state.selection.empty);
346
+
347
+ sync();
348
+ editor.on('selectionUpdate', sync);
349
+ editor.on('transaction', sync);
350
+
351
+ return () => {
352
+ editor.off('selectionUpdate', sync);
353
+ editor.off('transaction', sync);
354
+ };
355
+ }, [editor]);
356
+
357
+ const canOfferFixes = isCortexAiActive && Boolean(editor);
358
+
359
+ const handleFix = React.useCallback(
360
+ async (issue: SeoIssue) => {
361
+ if (!editor || fixingIssueId !== null) {
362
+ return;
363
+ }
364
+
365
+ setFixingIssueId(issue.id);
366
+ setFixErrors((previous) => {
367
+ const next = { ...previous };
368
+ delete next[issue.id];
369
+ return next;
370
+ });
371
+
372
+ // Everything about the target is captured before the await: the document
373
+ // can change while the request is in flight, and applying the answer to a
374
+ // position derived from the post-response state would drop the fragment
375
+ // somewhere the author never asked for.
376
+ const abortController = new AbortController();
377
+ const timeoutId = window.setTimeout(() => abortController.abort(), SEO_FIX_TIMEOUT_MS);
378
+ const wasEditorEmpty = editor.isEmpty;
379
+ const selectionBefore = editor.state.selection;
380
+ const selectionHadRange = !selectionBefore.empty;
381
+ const selectionFrom = selectionBefore.from;
382
+ const selectionTo = selectionBefore.to;
383
+ const selectedText = selectionHadRange
384
+ ? editor.state.doc.textBetween(selectionFrom, selectionTo, ' ').trim()
385
+ : '';
386
+ const insertionMode: SeoFixInsertionMode = wasEditorEmpty
387
+ ? 'replace-empty-document'
388
+ : selectionHadRange
389
+ ? 'replace-selection'
390
+ : 'append-to-end';
391
+
392
+ try {
393
+ // Sandbox installs have no server-side Cortex AI credentials, so the
394
+ // browser forwards the visitor's own OpenRouter key from localStorage.
395
+ // `NotionEditor.handleAiGenerate` still inlines this — it cannot import
396
+ // app code — but every call site inside the CMS goes through the shared
397
+ // builder, which is the whole reason that module exists: a header name
398
+ // or storage key that changes in one copy and not the other fails
399
+ // silently, authenticating one AI button and not the next.
400
+ const headers = buildCortexAiRequestHeaders();
401
+
402
+ const context = buildSeoFixContext({
403
+ documentText: editor.getText().trim(),
404
+ headingOutline: audit?.headings.map((heading) => `H${heading.level} ${heading.text}`),
405
+ insertionMode,
406
+ selectedText,
407
+ wordCount: audit?.readability.wordCount ?? null,
408
+ });
409
+ const prompt = buildSeoFixPrompt({
410
+ issue,
411
+ keyword: activeKeyword,
412
+ wordCount: audit?.readability.wordCount ?? null,
413
+ });
414
+
415
+ // The body carries `context` and `prompt` and nothing else: the route
416
+ // validates with a `z.strictObject`, so one stray key is a flat 400.
417
+ const response = await fetch('/api/ai/generate-blocks', {
418
+ body: JSON.stringify({ context, prompt }),
419
+ headers,
420
+ method: 'POST',
421
+ signal: abortController.signal,
422
+ });
423
+ const payload = await response.json();
424
+
425
+ if (!response.ok) {
426
+ throw new Error(payload?.error || 'Cortex AI could not generate content.');
427
+ }
428
+
429
+ if (!payload || typeof payload.html !== 'string' || !payload.html.trim()) {
430
+ throw new Error('Cortex AI returned an invalid HTML fragment.');
431
+ }
432
+
433
+ if (wasEditorEmpty || editor.isEmpty) {
434
+ editor.commands.setContent(payload.html);
435
+ } else if (selectionHadRange) {
436
+ // The document may have shrunk while the request was outstanding, so
437
+ // both ends are clamped: ProseMirror throws on an out-of-range
438
+ // position, which would lose the generated fragment entirely.
439
+ const docEnd = editor.state.doc.content.size;
440
+ const from = Math.min(selectionFrom, docEnd);
441
+ const to = Math.min(selectionTo, docEnd);
442
+
443
+ editor.chain().focus().insertContentAt({ from, to }, payload.html).run();
444
+ } else {
445
+ editor.chain().focus().insertContentAt(editor.state.doc.content.size, payload.html).run();
446
+ }
447
+
448
+ toast.success('Cortex AI applied a fix. Review it before publishing.');
449
+ } catch (error) {
450
+ const message =
451
+ error instanceof DOMException && error.name === 'AbortError'
452
+ ? 'Cortex AI took too long to respond. Please try again.'
453
+ : error instanceof Error
454
+ ? error.message
455
+ : 'Cortex AI could not generate content.';
456
+
457
+ setFixErrors((previous) => ({ ...previous, [issue.id]: message }));
458
+ } finally {
459
+ window.clearTimeout(timeoutId);
460
+ setFixingIssueId(null);
461
+ }
462
+ },
463
+ [activeKeyword, audit, editor, fixingIssueId]
464
+ );
465
+
466
+ const keywordStats = audit?.keyword ?? null;
467
+ const densityState: 'high' | 'low' | 'ok' = !keywordStats
468
+ ? 'low'
469
+ : keywordStats.density < KEYWORD_DENSITY_MINIMUM
470
+ ? 'low'
471
+ : keywordStats.density > KEYWORD_DENSITY_MAXIMUM
472
+ ? 'high'
473
+ : 'ok';
474
+
475
+ return (
476
+ <div className={cn('flex flex-col gap-4 p-4 text-sm', className)}>
477
+ <header className="space-y-1">
478
+ <h3 className="text-sm font-semibold">{SCOPE_HEADING[scope]}</h3>
479
+ <p className="text-xs text-muted-foreground">{SCOPE_DESCRIPTION[scope]}</p>
480
+ <p className="text-xs text-muted-foreground">
481
+ Updates automatically as you write. Nothing here is sent anywhere until you ask Cortex
482
+ AI to fix something.
483
+ </p>
484
+ </header>
485
+
486
+ <div className="space-y-2">
487
+ <Label className="text-xs" htmlFor={keywordInputId}>
488
+ Focus keyphrase
489
+ </Label>
490
+ <Input
491
+ className="h-8 text-sm"
492
+ id={keywordInputId}
493
+ onChange={(event) => {
494
+ const value = event.target.value;
495
+ if (isKeywordControlled) {
496
+ onKeywordChange?.(value);
497
+ } else {
498
+ setInternalKeyword(value);
499
+ }
500
+ }}
501
+ placeholder="e.g. organic green tea"
502
+ value={activeKeyword}
503
+ />
504
+ <p className="text-xs text-muted-foreground">
505
+ The phrase this page should rank for. Leave it blank to skip the keyphrase checks.
506
+ </p>
507
+ </div>
508
+
509
+ {/* A polite live region rather than an assertive one: the score changing
510
+ while you type is information, not an interruption. */}
511
+ <div aria-live="polite" className="sr-only">
512
+ {audit ? `SEO score ${audit.score} out of 100.` : 'Analysing the document.'}
513
+ </div>
514
+
515
+ {isDocumentTooLarge ? (
516
+ <div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
517
+ <FileWarning
518
+ aria-hidden="true"
519
+ className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400"
520
+ />
521
+ <p>
522
+ This document is too large for the live audit ({sourceLength.toLocaleString()}{' '}
523
+ characters). Grading it on every pause would freeze the editor, so the analysis is
524
+ paused until there is less text to read.
525
+ </p>
526
+ </div>
527
+ ) : !audit ? (
528
+ <div className="flex items-center gap-2 rounded-md border bg-muted/40 p-3 text-xs text-muted-foreground">
529
+ <Loader2 aria-hidden="true" className="h-4 w-4 animate-spin" />
530
+ Analysing the document…
531
+ </div>
532
+ ) : (
533
+ <>
534
+ <SeoScoreDial band={audit.scoreBand} score={audit.score} scope={scope} />
535
+
536
+ {isRecomputePending && (
537
+ <p className="text-xs text-muted-foreground">Recalculating…</p>
538
+ )}
539
+
540
+ {audit.checks.length > 0 && (
541
+ <section className="space-y-2">
542
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
543
+ Checks
544
+ </h4>
545
+ <ul className="space-y-1">
546
+ {audit.checks.map((check) => (
547
+ <li key={check.id} className="flex items-start gap-2 text-xs">
548
+ {check.passed ? (
549
+ <Check
550
+ aria-hidden="true"
551
+ className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400"
552
+ />
553
+ ) : (
554
+ <X
555
+ aria-hidden="true"
556
+ className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600 dark:text-red-400"
557
+ />
558
+ )}
559
+ <span className={cn('flex-1', check.passed ? '' : 'font-medium')}>
560
+ {/* Spelled out for screen readers, because the tick and
561
+ the cross are the only visual difference between a
562
+ passing and a failing row. */}
563
+ <span className="sr-only">{check.passed ? 'Passed: ' : 'Failed: '}</span>
564
+ {check.label}
565
+ </span>
566
+ <span className="shrink-0 tabular-nums text-muted-foreground">
567
+ {check.weight} pts
568
+ </span>
569
+ </li>
570
+ ))}
571
+ </ul>
572
+ </section>
573
+ )}
574
+
575
+ <SeoIssueList
576
+ busyIssueId={fixingIssueId}
577
+ errors={fixErrors}
578
+ issues={audit.issues}
579
+ onFix={canOfferFixes ? (issue) => void handleFix(issue) : undefined}
580
+ />
581
+
582
+ {canOfferFixes && (
583
+ <p className="text-xs text-muted-foreground">
584
+ {hasSelection
585
+ ? 'A fix will rewrite the text you have selected.'
586
+ : 'Nothing is selected, so a fix will be added at the end of the block. Select a passage first to rewrite it in place.'}
587
+ </p>
588
+ )}
589
+
590
+ <section className="space-y-2">
591
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
592
+ Readability
593
+ </h4>
594
+ <dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
595
+ <dt className="text-muted-foreground">Flesch reading ease</dt>
596
+ <dd className="text-right font-medium tabular-nums">
597
+ {audit.readability.fleschReadingEase}
598
+ </dd>
599
+ <dt className="text-muted-foreground">Grade</dt>
600
+ <dd className="text-right font-medium">{audit.readability.grade}</dd>
601
+ <dt className="text-muted-foreground">Average sentence length</dt>
602
+ <dd className="text-right font-medium tabular-nums">
603
+ {audit.readability.averageSentenceLength} words
604
+ </dd>
605
+ <dt className="text-muted-foreground">Syllables per word</dt>
606
+ <dd className="text-right font-medium tabular-nums">
607
+ {audit.readability.averageSyllablesPerWord}
608
+ </dd>
609
+ <dt className="text-muted-foreground">Sentences</dt>
610
+ <dd className="text-right font-medium tabular-nums">
611
+ {audit.readability.sentenceCount}
612
+ </dd>
613
+ <dt className="text-muted-foreground">Words</dt>
614
+ <dd className="text-right font-medium tabular-nums">
615
+ {audit.readability.wordCount}
616
+ </dd>
617
+ <dt className="text-muted-foreground">Syllables</dt>
618
+ <dd className="text-right font-medium tabular-nums">
619
+ {audit.readability.syllableCount}
620
+ </dd>
621
+ </dl>
622
+ </section>
623
+
624
+ <section className="space-y-2">
625
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
626
+ Keyphrase
627
+ </h4>
628
+
629
+ {!keywordStats ? (
630
+ <p className="text-xs text-muted-foreground">
631
+ Set a focus keyphrase above to see how often and where it appears.
632
+ </p>
633
+ ) : (
634
+ <div className="space-y-3">
635
+ <div className="space-y-1.5">
636
+ <div className="flex items-baseline justify-between text-xs">
637
+ <span className="text-muted-foreground">Density</span>
638
+ <span className="font-medium tabular-nums">
639
+ {keywordStats.density}% &middot; {keywordStats.count}{' '}
640
+ {keywordStats.count === 1 ? 'match' : 'matches'}
641
+ </span>
642
+ </div>
643
+
644
+ {/*
645
+ Hand-drawn rather than the shared `Progress` primitive: that
646
+ component ignores its `max` and positions the indicator from
647
+ `value` as a raw percentage, so a 0-5% scale would render at
648
+ a twentieth of its true width. Drawing it here also lets the
649
+ 1.0%-2.5% target band be shaded into the track, which is the
650
+ one thing a bare bar cannot tell you.
651
+ */}
652
+ <div
653
+ aria-label="Keyphrase density"
654
+ aria-valuemax={DENSITY_SCALE_MAXIMUM}
655
+ aria-valuemin={0}
656
+ aria-valuenow={keywordStats.density}
657
+ aria-valuetext={`${keywordStats.density} percent. ${DENSITY_STATE_LABEL[densityState]} of ${KEYWORD_DENSITY_MINIMUM} to ${KEYWORD_DENSITY_MAXIMUM} percent.`}
658
+ className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
659
+ role="meter"
660
+ >
661
+ <div
662
+ aria-hidden="true"
663
+ className="absolute inset-y-0 bg-emerald-500/20"
664
+ style={{
665
+ left: `${(KEYWORD_DENSITY_MINIMUM / DENSITY_SCALE_MAXIMUM) * 100}%`,
666
+ width: `${((KEYWORD_DENSITY_MAXIMUM - KEYWORD_DENSITY_MINIMUM) / DENSITY_SCALE_MAXIMUM) * 100}%`,
667
+ }}
668
+ />
669
+ <div
670
+ aria-hidden="true"
671
+ className={cn(
672
+ 'absolute inset-y-0 left-0 rounded-full transition-[width]',
673
+ DENSITY_STATE_STYLE[densityState]
674
+ )}
675
+ style={{
676
+ width: `${Math.min(100, (keywordStats.density / DENSITY_SCALE_MAXIMUM) * 100)}%`,
677
+ }}
678
+ />
679
+ </div>
680
+
681
+ <p className="text-xs text-muted-foreground">
682
+ {DENSITY_STATE_LABEL[densityState]} — aim for {KEYWORD_DENSITY_MINIMUM}% to{' '}
683
+ {KEYWORD_DENSITY_MAXIMUM}%.
684
+ </p>
685
+ </div>
686
+
687
+ <ul className="space-y-1 text-xs">
688
+ <KeywordPlacement label="Appears in the H1" present={keywordStats.inHeading1} />
689
+ <KeywordPlacement
690
+ label="Appears in a subheading"
691
+ present={keywordStats.inSubheadings}
692
+ />
693
+ <KeywordPlacement
694
+ label="Appears in the first 100 words"
695
+ present={keywordStats.inFirst100Words}
696
+ />
697
+ </ul>
698
+ </div>
699
+ )}
700
+ </section>
701
+
702
+ {/*
703
+ Two different reasons for the same missing button, and they need
704
+ different sentences. At block scope the editor is merely late — it is
705
+ loaded with `ssr: false` behind a fetch — so the button really will
706
+ appear. At page scope there is no editor and never will be: a page is
707
+ a dozen blocks and a fix has to land in one of them, so the honest
708
+ instruction is to open that block. Rendering a Fix button here that
709
+ had nowhere to write would be worse than rendering none.
710
+ */}
711
+ {isCortexAiActive && !editor && (
712
+ <p className="text-xs text-muted-foreground">
713
+ {scope === 'page'
714
+ ? 'One-click fixes live in the block editors. Open the block a finding refers to and use its own SEO panel to have Cortex AI rewrite it.'
715
+ : 'One-click fixes appear once the editor has finished loading.'}
716
+ </p>
717
+ )}
718
+ </>
719
+ )}
720
+ </div>
721
+ );
722
+ }
723
+
724
+ /**
725
+ * One yes/no keyphrase placement row.
726
+ *
727
+ * The tick or cross is paired with an off-screen word so the state is never
728
+ * conveyed by the glyph and its colour alone.
729
+ */
730
+ function KeywordPlacement({ label, present }: { label: string; present: boolean }) {
731
+ return (
732
+ <li className="flex items-center gap-2">
733
+ {present ? (
734
+ <Check
735
+ aria-hidden="true"
736
+ className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400"
737
+ />
738
+ ) : (
739
+ <X aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
740
+ )}
741
+ <span>
742
+ <span className="sr-only">{present ? 'Yes: ' : 'No: '}</span>
743
+ {label}
744
+ </span>
745
+ </li>
746
+ );
747
+ }
748
+
749
+ export default SeoAuditPanel;