create-nextblock 0.15.10 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) 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 -0
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -2900
  30. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  31. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  32. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  33. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  34. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  35. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  37. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  38. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  39. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  40. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -552
  41. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
  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 +180 -3
  46. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
  47. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  48. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  49. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  50. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +12 -3
  51. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  52. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  53. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  54. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  55. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  56. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  58. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  59. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  60. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  61. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  63. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  64. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  65. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
  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/[id]/edit/EditPageClient.tsx +28 -0
  74. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +248 -5
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +240 -5
  77. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  78. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  79. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  80. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  81. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  82. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  83. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  84. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  85. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  86. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  90. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  91. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  92. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  93. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  96. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  98. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  99. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  100. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  101. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  102. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  103. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
  104. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
  105. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
  106. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
  107. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
  108. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
  109. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
  110. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  111. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  112. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  113. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  114. package/templates/nextblock-template/app/layout.tsx +671 -671
  115. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  116. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  117. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  118. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  119. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  120. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  121. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  122. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  123. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  124. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  125. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  126. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  127. package/templates/nextblock-template/app/providers.tsx +96 -96
  128. package/templates/nextblock-template/app/robots.ts +123 -0
  129. package/templates/nextblock-template/app/sitemap.ts +1 -1
  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 -0
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
  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 -0
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
  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 -0
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
  231. package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -167
  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 +0 -1
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/proxy.ts +240 -20
  256. package/templates/nextblock-template/scripts/backup.js +115 -115
  257. package/templates/nextblock-template/scripts/restore.js +385 -385
  258. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  259. package/templates/nextblock-template/tailwind.config.js +25 -25
  260. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  261. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  262. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  263. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  264. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  265. package/tsconfig.base.json +3 -3
  266. package/templates/nextblock-template/app/robots.txt/route.ts +0 -32
@@ -0,0 +1,242 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { auditSeo, buildSeoDocument, type SeoIssue } from '@nextblock-cms/utils/seo';
4
+
5
+ import {
6
+ buildSeoFixContext,
7
+ buildSeoFixPrompt,
8
+ canFixIssueWithCortexAi,
9
+ SEO_FIX_CONTEXT_MAX_LENGTH,
10
+ SEO_FIX_PROMPT_MAX_LENGTH,
11
+ SEO_FIX_PROMPT_MIN_LENGTH,
12
+ } from './fix-prompts';
13
+
14
+ /**
15
+ * The audit panel cannot be rendered in a test — there is no DOM environment in
16
+ * this workspace and no React testing library — so these two builders are the
17
+ * only part of the "Fix with Cortex AI" path that can be pinned down. They are
18
+ * also the part where a mistake is invisible: an over-long prompt comes back as
19
+ * a bare 400 from a `z.strictObject` route, and a prompt missing its numbers
20
+ * produces a plausible-looking rewrite that does not actually fix anything.
21
+ */
22
+
23
+ function issue(overrides: Partial<SeoIssue> = {}): SeoIssue {
24
+ return {
25
+ category: 'readability',
26
+ detail: 'Sentences average 34 words; 25 or fewer is easier to follow.',
27
+ fixable: true,
28
+ fixPrompt: 'Break the longest sentences into shorter ones.',
29
+ id: 'readability-long-sentences',
30
+ message: 'The sentences are long.',
31
+ severity: 'warning',
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ describe('canFixIssueWithCortexAi', () => {
37
+ it('offers a fix when the audit marked the issue fixable and supplied an instruction', () => {
38
+ expect(canFixIssueWithCortexAi(issue())).toBe(true);
39
+ });
40
+
41
+ it('refuses an issue the audit marked unfixable', () => {
42
+ // A missing H1 is the real case: the audit deliberately declines to invent
43
+ // a page title, because that is an editorial decision rather than a rewrite.
44
+ expect(
45
+ canFixIssueWithCortexAi(
46
+ issue({
47
+ category: 'headings',
48
+ fixable: false,
49
+ fixPrompt: undefined,
50
+ id: 'headings-missing-h1',
51
+ })
52
+ )
53
+ ).toBe(false);
54
+ });
55
+
56
+ it('refuses a fixable issue that carries no instruction', () => {
57
+ // Without `fixPrompt` the request would be assembled entirely from the
58
+ // problem statement, and the model would answer by describing the problem.
59
+ expect(canFixIssueWithCortexAi(issue({ fixPrompt: undefined }))).toBe(false);
60
+ expect(canFixIssueWithCortexAi(issue({ fixPrompt: ' ' }))).toBe(false);
61
+ });
62
+
63
+ it('agrees with the audit engine on a real document', () => {
64
+ // Guards against the predicate drifting away from the engine's own verdicts:
65
+ // every issue this panel would offer to fix must actually carry the
66
+ // instruction the request is built from.
67
+ const document = buildSeoDocument(
68
+ '<h1>Tea</h1><h1>More tea</h1><p>Short page about tea leaves and water.</p>'
69
+ );
70
+ const result = auditSeo({ document, keyword: 'tea' });
71
+ const fixable = result.issues.filter(canFixIssueWithCortexAi);
72
+
73
+ expect(fixable.length).toBeGreaterThan(0);
74
+ for (const finding of fixable) {
75
+ expect(finding.fixPrompt?.trim()).toBeTruthy();
76
+ }
77
+ expect(result.issues.some((finding) => finding.id === 'headings-missing-h1')).toBe(false);
78
+ });
79
+
80
+ it('never offers to fix missing alt text, which needs the vision generator instead', () => {
81
+ const document = buildSeoDocument(
82
+ `<h1>Gallery</h1><p>${'photo '.repeat(40)}</p><img src="/a.png" alt="">`
83
+ );
84
+ const missingAlt = auditSeo({ document }).issues.find(
85
+ (finding) => finding.id === 'images-missing-alt'
86
+ );
87
+
88
+ expect(missingAlt).toBeDefined();
89
+ expect(canFixIssueWithCortexAi(missingAlt as SeoIssue)).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe('buildSeoFixPrompt', () => {
94
+ it('leads with the audit instruction and repeats the concrete numbers', () => {
95
+ const prompt = buildSeoFixPrompt({ issue: issue(), wordCount: 118 });
96
+
97
+ expect(prompt.startsWith('Break the longest sentences into shorter ones.')).toBe(true);
98
+ expect(prompt).toContain('The sentences are long.');
99
+ expect(prompt).toContain('Sentences average 34 words');
100
+ expect(prompt).toContain('118 words');
101
+ });
102
+
103
+ it('names the focus keyphrase only when one is set', () => {
104
+ expect(buildSeoFixPrompt({ issue: issue(), keyword: 'green tea' })).toContain(
105
+ 'focus keyphrase for this page is "green tea"'
106
+ );
107
+ expect(buildSeoFixPrompt({ issue: issue(), keyword: ' ' })).not.toContain('focus keyphrase');
108
+ expect(buildSeoFixPrompt({ issue: issue(), keyword: null })).not.toContain('focus keyphrase');
109
+ });
110
+
111
+ it('omits the word count when the caller has none', () => {
112
+ const prompt = buildSeoFixPrompt({ issue: issue() });
113
+
114
+ expect(prompt).not.toContain('currently runs to');
115
+ expect(prompt).not.toContain('NaN');
116
+ });
117
+
118
+ it('states the output contract so the fragment can be inserted verbatim', () => {
119
+ const prompt = buildSeoFixPrompt({ issue: issue() });
120
+
121
+ expect(prompt).toContain('HTML fragment');
122
+ expect(prompt).toContain('code fence');
123
+ });
124
+
125
+ it('survives an issue with no detail sentence', () => {
126
+ const prompt = buildSeoFixPrompt({ issue: issue({ detail: undefined }) });
127
+
128
+ expect(prompt).toContain('The SEO audit reported: The sentences are long.');
129
+ expect(prompt).not.toContain('undefined');
130
+ });
131
+
132
+ it('stays inside the schema length bounds even with an absurd instruction', () => {
133
+ const prompt = buildSeoFixPrompt({
134
+ issue: issue({ detail: 'x'.repeat(9000), fixPrompt: 'y'.repeat(9000) }),
135
+ keyword: 'z'.repeat(500),
136
+ wordCount: 4321,
137
+ });
138
+
139
+ expect(prompt.length).toBeLessThanOrEqual(SEO_FIX_PROMPT_MAX_LENGTH);
140
+ expect(prompt.length).toBeGreaterThanOrEqual(SEO_FIX_PROMPT_MIN_LENGTH);
141
+ });
142
+
143
+ it('produces a prompt long enough to pass the minimum for every real fixable issue', () => {
144
+ const document = buildSeoDocument(
145
+ '<h1>Tea</h1><h1>Tea again</h1><h4>Deep</h4><h2></h2><p>A very short page.</p>'
146
+ );
147
+ const result = auditSeo({
148
+ document,
149
+ keyword: 'coffee',
150
+ metaDescription: '',
151
+ metaTitle: '',
152
+ });
153
+ const fixable = result.issues.filter(canFixIssueWithCortexAi);
154
+
155
+ expect(fixable.length).toBeGreaterThan(3);
156
+ for (const finding of fixable) {
157
+ const prompt = buildSeoFixPrompt({
158
+ issue: finding,
159
+ keyword: 'coffee',
160
+ wordCount: result.readability.wordCount,
161
+ });
162
+ expect(prompt.length).toBeGreaterThanOrEqual(SEO_FIX_PROMPT_MIN_LENGTH);
163
+ expect(prompt.length).toBeLessThanOrEqual(SEO_FIX_PROMPT_MAX_LENGTH);
164
+ }
165
+ });
166
+ });
167
+
168
+ describe('buildSeoFixContext', () => {
169
+ it('short-circuits an empty document rather than describing nothing', () => {
170
+ const context = buildSeoFixContext({
171
+ documentText: '',
172
+ insertionMode: 'replace-empty-document',
173
+ wordCount: 0,
174
+ });
175
+
176
+ expect(context).toBe(
177
+ 'Insertion target: empty editor. Create the initial content for this document.'
178
+ );
179
+ });
180
+
181
+ it('quotes the tail of the document when appending', () => {
182
+ const context = buildSeoFixContext({
183
+ documentText: `START ${'middle '.repeat(400)}END`,
184
+ insertionMode: 'append-to-end',
185
+ });
186
+
187
+ expect(context).toContain('END');
188
+ expect(context).not.toContain('START');
189
+ expect(context).toContain('Continue after the existing content');
190
+ });
191
+
192
+ it('quotes the head of the document when replacing a selection', () => {
193
+ const context = buildSeoFixContext({
194
+ documentText: `START ${'middle '.repeat(400)}END`,
195
+ insertionMode: 'replace-selection',
196
+ selectedText: 'the highlighted sentence',
197
+ });
198
+
199
+ expect(context).toContain('START');
200
+ expect(context).not.toContain('END');
201
+ expect(context).toContain('Selected text:\nthe highlighted sentence');
202
+ expect(context).toContain('Replace only the selected content');
203
+ });
204
+
205
+ it('includes the heading outline when one is supplied', () => {
206
+ const context = buildSeoFixContext({
207
+ documentText: 'Body copy.',
208
+ headingOutline: ['H1 Tea', 'H3 Water temperature'],
209
+ insertionMode: 'append-to-end',
210
+ });
211
+
212
+ expect(context).toContain('Heading outline:\nH1 Tea\nH3 Water temperature');
213
+ });
214
+
215
+ it('drops the optional sections instead of emitting empty labels', () => {
216
+ const context = buildSeoFixContext({
217
+ documentText: ' ',
218
+ headingOutline: [],
219
+ insertionMode: 'append-to-end',
220
+ selectedText: ' ',
221
+ wordCount: null,
222
+ });
223
+
224
+ expect(context).not.toContain('Heading outline');
225
+ expect(context).not.toContain('Selected text');
226
+ expect(context).not.toContain('Existing editor text');
227
+ expect(context).not.toContain('words.');
228
+ expect(context.startsWith('Insertion target: append-to-end.')).toBe(true);
229
+ });
230
+
231
+ it('never exceeds the 2000 character cap the route enforces', () => {
232
+ const context = buildSeoFixContext({
233
+ documentText: 'word '.repeat(50_000),
234
+ headingOutline: Array.from({ length: 200 }, (_, index) => `H2 Section ${index}`),
235
+ insertionMode: 'replace-selection',
236
+ selectedText: 'selected '.repeat(5_000),
237
+ wordCount: 50_000,
238
+ });
239
+
240
+ expect(context.length).toBeLessThanOrEqual(SEO_FIX_CONTEXT_MAX_LENGTH);
241
+ });
242
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Turns an SEO finding into the two strings the Cortex AI rewrite endpoint takes.
3
+ *
4
+ * The audit panel that calls this is a React component with a network call, a
5
+ * debounce and a Tiptap instance in it, and this repository has no DOM test
6
+ * environment (`@testing-library/react` is deliberately not a dependency), so
7
+ * the panel itself cannot be exercised by a test. Everything in it that can get
8
+ * the *content* of a request wrong therefore lives here instead, as ordinary
9
+ * functions over plain data: which issues may offer a Fix button at all, what
10
+ * instruction the model is given, and what it is told about the surrounding
11
+ * document. What is left in the component is wiring — state, fetch, insertion —
12
+ * which fails loudly in the browser rather than silently producing a bad prompt.
13
+ *
14
+ * The hard constraint this module exists to honour is the request schema on
15
+ * `POST /api/ai/generate-blocks`, which is a `z.strictObject`:
16
+ *
17
+ * { context?: string (max 2000); prompt: string (min 3, max 4000) }
18
+ *
19
+ * Strict means an unrecognised key is a 400 with no useful message, and the
20
+ * length caps are enforced server-side, so both strings are clamped here rather
21
+ * than hoped about at the call site. Every limit below mirrors that schema; if
22
+ * the route's schema ever changes, these constants change with it.
23
+ */
24
+
25
+ import type { SeoIssue } from '@nextblock-cms/utils/seo';
26
+
27
+ /** Mirrors `context: z.string().max(2000)` on the generate-blocks schema. */
28
+ export const SEO_FIX_CONTEXT_MAX_LENGTH = 2000;
29
+
30
+ /** Mirrors `prompt: z.string().max(4000)` on the generate-blocks schema. */
31
+ export const SEO_FIX_PROMPT_MAX_LENGTH = 4000;
32
+
33
+ /** Mirrors `prompt: z.string().min(3)` on the generate-blocks schema. */
34
+ export const SEO_FIX_PROMPT_MIN_LENGTH = 3;
35
+
36
+ /**
37
+ * How much of the document body to quote back to the model.
38
+ *
39
+ * Sized so the quoted body plus the framing lines around it still fit inside
40
+ * `SEO_FIX_CONTEXT_MAX_LENGTH` without the final clamp having to bite, because
41
+ * a clamp that lands in the *middle* of the assembled context would cut the
42
+ * trailing sections off entirely and leave the model reading a truncated
43
+ * excerpt as if it were the whole page.
44
+ */
45
+ export const SEO_FIX_CONTEXT_BODY_BUDGET = 1200;
46
+
47
+ /** How much of the author's selection to quote when fixing a selected passage. */
48
+ export const SEO_FIX_CONTEXT_SELECTION_BUDGET = 600;
49
+
50
+ /**
51
+ * Where the rewritten fragment is going to land, which changes what the model
52
+ * should produce: a whole document, a replacement for the highlighted passage,
53
+ * or a continuation appended after everything that already exists.
54
+ *
55
+ * The three values match the branches the editor's own prompt bar already uses
56
+ * (`buildAiEditorContext` in `libs/editor/src/lib/NotionEditor.tsx`), so a
57
+ * one-click fix and a hand-typed prompt are framed to the model identically.
58
+ */
59
+ export type SeoFixInsertionMode =
60
+ | 'append-to-end'
61
+ | 'replace-empty-document'
62
+ | 'replace-selection';
63
+
64
+ export interface SeoFixContextInput {
65
+ /** Plain text of the document being audited, used to ground the rewrite. */
66
+ documentText: string;
67
+ /** Headings in document order, already formatted as e.g. `H2 Ingredients`. */
68
+ headingOutline?: string[];
69
+ insertionMode: SeoFixInsertionMode;
70
+ /** The text the author had highlighted, when the fix targets a selection. */
71
+ selectedText?: string | null;
72
+ /** Word count from the audit, so the model is told the real number. */
73
+ wordCount?: number | null;
74
+ }
75
+
76
+ export interface SeoFixPromptInput {
77
+ issue: SeoIssue;
78
+ /** The focus keyphrase, when one is set; blank and `null` are equivalent. */
79
+ keyword?: string | null;
80
+ /** Word count from the audit, quoted for the findings that are about length. */
81
+ wordCount?: number | null;
82
+ }
83
+
84
+ /**
85
+ * Cuts a string to `limit` characters without ever exceeding it.
86
+ *
87
+ * Deliberately a hard slice rather than a word-boundary trim: the caps are a
88
+ * server-side validation boundary, and "nearly 2000" is a rejected request. A
89
+ * ragged final word costs the model nothing; a 400 costs the author their click.
90
+ */
91
+ function clampLength(value: string, limit: number): string {
92
+ return value.length <= limit ? value : value.slice(0, limit);
93
+ }
94
+
95
+ /**
96
+ * Whether the "Fix with Cortex AI" button should appear for this finding.
97
+ *
98
+ * Two conditions, and both matter. `fixable` is the audit engine's own verdict,
99
+ * and it says no to more than you might expect: a missing H1 and a missing
100
+ * keyphrase are unfixable because choosing them is an editorial decision, and
101
+ * missing image alt text is unfixable *here* because describing pixels needs the
102
+ * vision-backed generator in the media library rather than a prose rewrite. A
103
+ * non-empty `fixPrompt` is then required because it is the entire instruction
104
+ * handed to the model — an issue marked fixable without one would send a prompt
105
+ * assembled from nothing but the problem statement, and the model would answer
106
+ * by restating the problem instead of repairing it.
107
+ */
108
+ export function canFixIssueWithCortexAi(issue: SeoIssue): boolean {
109
+ return issue.fixable && typeof issue.fixPrompt === 'string' && issue.fixPrompt.trim() !== '';
110
+ }
111
+
112
+ /**
113
+ * Builds the `prompt` field: the audit's own repair instruction, plus the
114
+ * concrete numbers behind the finding, plus the output contract.
115
+ *
116
+ * The numbers come from `issue.detail`, which every auditor writes as a sentence
117
+ * that already contains them ("This page has 118 words; 300 or more…"), so
118
+ * quoting it verbatim keeps the figure on screen and the figure in the request
119
+ * from ever drifting apart. Restating the message alongside the instruction is
120
+ * not redundancy: `fixPrompt` is phrased as a bare command, and a model told
121
+ * only "shorten the longest sentences" has no idea by how much.
122
+ *
123
+ * The output contract at the end exists because the response is inserted
124
+ * straight into a Tiptap document. A model that answered with a markdown code
125
+ * fence, a preamble, or a full HTML document would have all of that rendered as
126
+ * literal content in the page the author is editing.
127
+ */
128
+ export function buildSeoFixPrompt(input: SeoFixPromptInput): string {
129
+ const { issue, keyword, wordCount } = input;
130
+ const instruction = (issue.fixPrompt ?? '').trim();
131
+ const trimmedKeyword = (keyword ?? '').trim();
132
+
133
+ const lines: Array<string | null> = [
134
+ instruction,
135
+ '',
136
+ `The SEO audit reported: ${issue.message}${issue.detail ? ` ${issue.detail}` : ''}`,
137
+ trimmedKeyword === '' ? null : `The focus keyphrase for this page is "${trimmedKeyword}".`,
138
+ typeof wordCount === 'number' && Number.isFinite(wordCount)
139
+ ? `The document currently runs to ${wordCount} words.`
140
+ : null,
141
+ '',
142
+ 'Return only the corrected content as an HTML fragment using semantic tags such as <h2>, <p>, <ul> and <li>. Do not wrap it in a code fence, do not add commentary, and do not emit <html>, <head> or <body>.',
143
+ ];
144
+
145
+ const prompt = lines
146
+ .filter((line): line is string => line !== null)
147
+ .join('\n')
148
+ .trim();
149
+
150
+ return clampLength(prompt, SEO_FIX_PROMPT_MAX_LENGTH);
151
+ }
152
+
153
+ /**
154
+ * Builds the `context` field: what the model needs to know about the document
155
+ * it is editing, framed by where its answer is going to be inserted.
156
+ *
157
+ * This mirrors `buildAiEditorContext` in the editor's prompt bar on purpose.
158
+ * Both feed the same endpoint, and a fix that described the document
159
+ * differently from a hand-typed prompt would get systematically different
160
+ * output from the same model for no reason an author could see.
161
+ *
162
+ * The body excerpt is taken from the *end* of the document when appending and
163
+ * from the *start* otherwise, because those are the parts adjacent to where the
164
+ * answer lands: a continuation needs to know what it is continuing, while a
165
+ * rewrite anchored at the top is best grounded by the opening.
166
+ */
167
+ export function buildSeoFixContext(input: SeoFixContextInput): string {
168
+ const { documentText, headingOutline, insertionMode, selectedText, wordCount } = input;
169
+
170
+ // An empty document has no context worth sending, and saying so is more
171
+ // useful to the model than an excerpt of nothing followed by an outline of
172
+ // nothing. This matches the editor's own early return for the same case.
173
+ if (insertionMode === 'replace-empty-document') {
174
+ return 'Insertion target: empty editor. Create the initial content for this document.';
175
+ }
176
+
177
+ const body = (documentText ?? '').trim();
178
+ const selection = (selectedText ?? '').trim();
179
+ const excerpt =
180
+ insertionMode === 'append-to-end'
181
+ ? body.slice(-SEO_FIX_CONTEXT_BODY_BUDGET)
182
+ : body.slice(0, SEO_FIX_CONTEXT_BODY_BUDGET);
183
+
184
+ const parts: Array<string | null> = [
185
+ `Insertion target: ${insertionMode}.`,
186
+ insertionMode === 'append-to-end'
187
+ ? 'Continue after the existing content. Do not repeat existing headings, paragraphs, or lists unless the fix explicitly calls for a rewrite.'
188
+ : 'Replace only the selected content. Keep the surrounding editor content in mind for continuity.',
189
+ typeof wordCount === 'number' && Number.isFinite(wordCount)
190
+ ? `The document has ${wordCount} words.`
191
+ : null,
192
+ headingOutline && headingOutline.length > 0
193
+ ? `Heading outline:\n${headingOutline.join('\n')}`
194
+ : null,
195
+ selection === ''
196
+ ? null
197
+ : `Selected text:\n${selection.slice(0, SEO_FIX_CONTEXT_SELECTION_BUDGET)}`,
198
+ excerpt === '' ? null : `Existing editor text:\n${excerpt}`,
199
+ ];
200
+
201
+ const context = parts.filter((part): part is string => part !== null).join('\n\n');
202
+
203
+ return clampLength(context, SEO_FIX_CONTEXT_MAX_LENGTH);
204
+ }
@@ -0,0 +1,140 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Shared live state for the page-level SEO audit.
5
+ *
6
+ * WHY THIS EXISTS. The pieces an SEO audit needs are spread across three sibling
7
+ * components that have no relationship to each other: `BlockEditorArea` owns the
8
+ * live `blocks` array (it is the only client component that holds all of them),
9
+ * `PageForm` / `PostForm` own the meta title and description as local form state,
10
+ * and the panel that has to grade all of it renders next to both. Lifting any one
11
+ * of those into another would mean restructuring an edit screen that works; a small
12
+ * context is the cheaper and less invasive answer.
13
+ *
14
+ * WHY THE AUDIT IS PAGE-LEVEL AND NOT PER-BLOCK. The first version of this feature
15
+ * mounted the panel inside the rich-text editor, where it could only ever see one
16
+ * block. That produced confidently wrong results: a single paragraph block was told
17
+ * it had no H1 and fewer than 300 words, neither of which is a property a block can
18
+ * have. Headings in NextBlock live in two places — standalone `heading` blocks and
19
+ * h1–h6 nodes inside a `text` block's HTML — and blocks nested in a `section` live
20
+ * in `content.column_blocks[column][index]` rather than in the top-level array. Only
21
+ * something walking the whole tree can answer "does this page have exactly one H1".
22
+ * So the page-level panel is the real audit, and the in-editor panel is narrowed to
23
+ * the handful of checks a single block genuinely owns.
24
+ *
25
+ * PUBLISHING IS EFFECT-DRIVEN, deliberately. Both producers call their setter from a
26
+ * `useEffect` keyed on the value they publish, never during render — a child calling
27
+ * a parent's setState while rendering is the classic "Cannot update a component
28
+ * while rendering a different component" warning, and with the blocks array it would
29
+ * be an update loop rather than just a warning.
30
+ *
31
+ * `usePageSeo()` RETURNS NULL OUTSIDE A PROVIDER on purpose. `PageForm`,
32
+ * `BlockEditorArea` and `TextBlockEditor` are all reachable from surfaces that have
33
+ * no page-level audit — the product editor is the live example — and none of them
34
+ * should crash or need a second code path just because the panel is absent.
35
+ */
36
+
37
+ import React, {
38
+ createContext,
39
+ useCallback,
40
+ useContext,
41
+ useMemo,
42
+ useState,
43
+ type ReactNode,
44
+ } from 'react';
45
+
46
+ /** Everything the page-level audit grades, as it currently stands in the editor. */
47
+ export interface PageSeoSnapshot {
48
+ /**
49
+ * The live blocks array, exactly as `BlockEditorArea` holds it. Typed as
50
+ * `unknown[]` because this module has no business knowing the block union — the
51
+ * walker in `page-document.ts` is defensive about shape anyway, since block
52
+ * content is `Json` and a custom block can carry anything.
53
+ */
54
+ blocks: unknown[];
55
+ metaDescription: string | null;
56
+ metaTitle: string | null;
57
+ }
58
+
59
+ export interface PageSeoContextValue {
60
+ /** The focus keyphrase, held here so it survives switching between blocks. */
61
+ keyword: string;
62
+ setBlocks: (blocks: unknown[]) => void;
63
+ setKeyword: (keyword: string) => void;
64
+ setMeta: (meta: { metaDescription: string | null; metaTitle: string | null }) => void;
65
+ snapshot: PageSeoSnapshot;
66
+ }
67
+
68
+ const PageSeoContext = createContext<PageSeoContextValue | null>(null);
69
+
70
+ export interface PageSeoProviderProps {
71
+ children: ReactNode;
72
+ initialBlocks?: unknown[];
73
+ initialMetaDescription?: string | null;
74
+ initialMetaTitle?: string | null;
75
+ }
76
+
77
+ export function PageSeoProvider({
78
+ children,
79
+ initialBlocks = [],
80
+ initialMetaDescription = null,
81
+ initialMetaTitle = null,
82
+ }: PageSeoProviderProps) {
83
+ const [blocks, setBlocksState] = useState<unknown[]>(initialBlocks);
84
+ const [keyword, setKeyword] = useState('');
85
+ const [meta, setMetaState] = useState<{
86
+ metaDescription: string | null;
87
+ metaTitle: string | null;
88
+ }>({ metaDescription: initialMetaDescription, metaTitle: initialMetaTitle });
89
+
90
+ // Both setters bail when nothing actually changed. The blocks array is rebuilt on
91
+ // every keystroke in a block editor, so without the length-and-identity check
92
+ // below every character typed would re-render the provider and every consumer of
93
+ // this context, not just the panel that debounces its own work.
94
+ const setBlocks = useCallback((next: unknown[]) => {
95
+ setBlocksState((previous) =>
96
+ previous.length === next.length && previous.every((block, index) => block === next[index])
97
+ ? previous
98
+ : next,
99
+ );
100
+ }, []);
101
+
102
+ const setMeta = useCallback(
103
+ (next: { metaDescription: string | null; metaTitle: string | null }) => {
104
+ setMetaState((previous) =>
105
+ previous.metaDescription === next.metaDescription && previous.metaTitle === next.metaTitle
106
+ ? previous
107
+ : next,
108
+ );
109
+ },
110
+ [],
111
+ );
112
+
113
+ const value = useMemo<PageSeoContextValue>(
114
+ () => ({
115
+ keyword,
116
+ setBlocks,
117
+ setKeyword,
118
+ setMeta,
119
+ snapshot: {
120
+ blocks,
121
+ metaDescription: meta.metaDescription,
122
+ metaTitle: meta.metaTitle,
123
+ },
124
+ }),
125
+ [blocks, keyword, meta.metaDescription, meta.metaTitle, setBlocks, setMeta],
126
+ );
127
+
128
+ return <PageSeoContext.Provider value={value}>{children}</PageSeoContext.Provider>;
129
+ }
130
+
131
+ /**
132
+ * The page-level audit state, or `null` when there is no provider above.
133
+ *
134
+ * Callers must handle the null case rather than asserting — see the module docblock
135
+ * for why an edit surface without a page-level panel is a supported configuration
136
+ * and not a mistake.
137
+ */
138
+ export function usePageSeo(): PageSeoContextValue | null {
139
+ return useContext(PageSeoContext);
140
+ }