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.
- package/docker-template/.dockerignore +1 -1
- package/libs/db/tsconfig.lib.json +3 -3
- package/libs/editor/tsconfig.lib.json +3 -3
- package/libs/ui/tsconfig.lib.json +3 -3
- package/libs/utils/tsconfig.json +3 -3
- package/package.json +50 -50
- package/project.json +19 -19
- package/templates/nextblock-template/.dockerignore +1 -1
- package/templates/nextblock-template/.swcrc +30 -30
- package/templates/nextblock-template/AGENTS.md +9 -9
- package/templates/nextblock-template/CLAUDE.md +1 -1
- package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
- package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
- package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
- package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
- package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
- package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
- package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
- package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
- package/templates/nextblock-template/app/actions/email.ts +241 -241
- package/templates/nextblock-template/app/actions/formActions.ts +245 -245
- package/templates/nextblock-template/app/actions/interactions.ts +489 -489
- package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
- package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
- package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -0
- package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
- package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
- package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -2900
- package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
- package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
- package/templates/nextblock-template/app/api/search/route.ts +43 -43
- package/templates/nextblock-template/app/api/view/route.ts +114 -114
- package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
- package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
- package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
- package/templates/nextblock-template/app/cart/page.tsx +7 -7
- package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
- package/templates/nextblock-template/app/checkout/page.tsx +57 -57
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -552
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
- package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
- package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +180 -3
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
- package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
- package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
- package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +12 -3
- package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
- package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
- package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
- package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
- package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
- package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
- package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/layout.tsx +101 -101
- package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
- package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
- package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
- package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
- package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
- package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
- package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
- package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
- package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
- package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +248 -5
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +240 -5
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
- package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
- package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
- package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
- package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
- package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
- package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
- package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
- package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
- package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
- package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
- package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
- package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
- package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
- package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
- package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
- package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
- package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
- package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
- package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
- package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
- package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
- package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
- package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
- package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
- package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
- package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
- package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
- package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
- package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
- package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
- package/templates/nextblock-template/app/layout.tsx +671 -671
- package/templates/nextblock-template/app/lib/seo.ts +319 -319
- package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
- package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
- package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
- package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
- package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
- package/templates/nextblock-template/app/profile/account-links.ts +22 -22
- package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
- package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
- package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
- package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
- package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
- package/templates/nextblock-template/app/providers.tsx +96 -96
- package/templates/nextblock-template/app/robots.ts +123 -0
- package/templates/nextblock-template/app/sitemap.ts +1 -1
- package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
- package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
- package/templates/nextblock-template/app/thread/layout.tsx +15 -15
- package/templates/nextblock-template/app/thread/page.tsx +98 -98
- package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
- package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
- package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
- package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
- package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
- package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
- package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
- package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
- package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
- package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
- package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
- package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
- package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
- package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
- package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
- package/templates/nextblock-template/components/Header.tsx +38 -38
- package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
- package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
- package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
- package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
- package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
- package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
- package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
- package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
- package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
- package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
- package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
- package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
- package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
- package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
- package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
- package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
- package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
- package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
- package/templates/nextblock-template/components/blocks/types.ts +7 -7
- package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
- package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
- package/templates/nextblock-template/components/form-message.tsx +32 -32
- package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
- package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
- package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
- package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
- package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
- package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
- package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
- package/templates/nextblock-template/components/submit-button.tsx +23 -23
- package/templates/nextblock-template/components/theme-icon.tsx +78 -78
- package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
- package/templates/nextblock-template/context/AuthContext.tsx +23 -23
- package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
- package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
- package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
- package/templates/nextblock-template/docs/README.md +42 -42
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
- package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
- package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
- package/templates/nextblock-template/index.d.ts +7 -7
- package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
- package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
- package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
- package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
- package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
- package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
- package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
- package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
- package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
- package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
- package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
- package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
- package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
- package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
- package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
- package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
- package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
- package/templates/nextblock-template/lib/messages/threads.ts +579 -579
- package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
- package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
- package/templates/nextblock-template/lib/search/types.ts +27 -27
- package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
- package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
- package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
- package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
- package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
- package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
- package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
- package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
- package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
- package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
- package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
- package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
- package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
- package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -167
- package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
- package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
- package/templates/nextblock-template/lib/site-url.test.ts +89 -89
- package/templates/nextblock-template/lib/site-url.ts +102 -102
- package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
- package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
- package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
- package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
- package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
- package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
- package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
- package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
- package/templates/nextblock-template/lib/zod-config.ts +5 -5
- package/templates/nextblock-template/next-env.d.ts +0 -1
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/postcss.config.js +6 -6
- package/templates/nextblock-template/proxy.ts +240 -20
- package/templates/nextblock-template/scripts/backup.js +115 -115
- package/templates/nextblock-template/scripts/restore.js +385 -385
- package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
- package/templates/nextblock-template/tailwind.config.js +25 -25
- package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
- package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
- package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
- package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
- package/templates/nextblock-template/tools/update.mjs +1303 -1303
- package/tsconfig.base.json +3 -3
- 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
|
+
}
|