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,448 @@
|
|
|
1
|
+
// app/cms/settings/seo/actions.ts
|
|
2
|
+
'use server';
|
|
3
|
+
|
|
4
|
+
import { revalidatePath } from 'next/cache';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
normalizeRedirectPath,
|
|
8
|
+
normalizeRobotsSettings,
|
|
9
|
+
validateRedirectRule,
|
|
10
|
+
type RedirectRule,
|
|
11
|
+
type RobotsSettings,
|
|
12
|
+
} from '@nextblock-cms/utils/seo';
|
|
13
|
+
|
|
14
|
+
import type { SettingsActionResult } from '../../../../lib/cms/action-result';
|
|
15
|
+
import { isSchemaMissingError, CMS_REDIRECTS_TABLE } from '../../../../lib/seo/redirect-store';
|
|
16
|
+
import { z } from '../../../../lib/zod-config';
|
|
17
|
+
import { toRedirectRules, type CmsRedirect, type RedirectInput } from './mappers';
|
|
18
|
+
import { requireAdminSupabaseClient } from './require-admin';
|
|
19
|
+
|
|
20
|
+
const SEO_SETTINGS_PATH = '/cms/settings/seo';
|
|
21
|
+
|
|
22
|
+
/** The `site_settings` row robots directives live in, seeded by migration 00000000000030. */
|
|
23
|
+
const ROBOTS_SETTINGS_KEY = 'seo_robots_settings';
|
|
24
|
+
|
|
25
|
+
/** Every column the admin list needs; the proxy deliberately selects fewer. */
|
|
26
|
+
const REDIRECT_ADMIN_COLUMNS =
|
|
27
|
+
'id, source_path, destination_path, status_code, is_active, created_at, updated_at';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Which caches a change here has to reach.
|
|
31
|
+
*
|
|
32
|
+
* The neighbouring settings actions invalidate a *tagged* public read — `updateTag`
|
|
33
|
+
* in logos/languages/extra-translations, `revalidateTag(tag, 'max')` in
|
|
34
|
+
* site-scripts and themes — because each of those feeds a cached component in the
|
|
35
|
+
* public layout. Nothing on this screen has such a tag, and inventing one would only
|
|
36
|
+
* name a cache that no reader consults: `/robots.txt` is a plain route handler
|
|
37
|
+
* addressed by path, and redirects are read by `proxy.ts`, which keeps its own
|
|
38
|
+
* module-level TTL cache that Next's tag system cannot see at all. So path
|
|
39
|
+
* revalidation is the matching call here, not a shortfall.
|
|
40
|
+
*
|
|
41
|
+
* The practical consequence, worth knowing before someone reports it as a bug: a
|
|
42
|
+
* redirect saved here becomes live once the proxy's own cache window lapses, not
|
|
43
|
+
* instantly. Robots changes are instant, because `/robots.txt` really is a Next
|
|
44
|
+
* route and `revalidateRobotsFile` below evicts it.
|
|
45
|
+
*/
|
|
46
|
+
function revalidateSeoSettings() {
|
|
47
|
+
revalidatePath(SEO_SETTINGS_PATH);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Additionally evicts the public file, so the operator's change is visible at once. */
|
|
51
|
+
function revalidateRobotsFile() {
|
|
52
|
+
revalidateSeoSettings();
|
|
53
|
+
revalidatePath('/robots.txt');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The admin gate, converted from a throw into a value.
|
|
58
|
+
*
|
|
59
|
+
* Every mutation below is invoked directly from a client component, and Next replaces
|
|
60
|
+
* the message of an uncaught Server Action error with a generic string in production
|
|
61
|
+
* builds — so "you do not have permission" would reach the operator as "An error
|
|
62
|
+
* occurred in the Server Components render", which tells them nothing and invites a
|
|
63
|
+
* support ticket. The shared helper throws because a Server Component wants it to;
|
|
64
|
+
* this wrapper is where that throw becomes `SettingsActionResult` data.
|
|
65
|
+
*/
|
|
66
|
+
async function requireAdminOrMessage(): Promise<
|
|
67
|
+
{ error: null; supabase: Awaited<ReturnType<typeof requireAdminSupabaseClient>>['supabase'] } | { error: string; supabase: null }
|
|
68
|
+
> {
|
|
69
|
+
try {
|
|
70
|
+
const { supabase } = await requireAdminSupabaseClient();
|
|
71
|
+
|
|
72
|
+
return { error: null, supabase };
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return {
|
|
75
|
+
error: error instanceof Error ? error.message : 'You do not have permission to manage SEO settings.',
|
|
76
|
+
supabase: null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Turns a PostgREST failure into something an operator can act on.
|
|
83
|
+
*
|
|
84
|
+
* The two cases worth naming are the ones that will actually happen. A missing table
|
|
85
|
+
* means the install has pulled this code but not yet run its migrations — a steady
|
|
86
|
+
* state that can last days, and one whose raw message ("Could not find the table
|
|
87
|
+
* 'public.cms_redirects' in the schema cache") reads like a bug in NextBlock rather
|
|
88
|
+
* than a step the operator has not taken. A unique violation means the source path is
|
|
89
|
+
* already claimed, which the application already checks; seeing it here means two
|
|
90
|
+
* admins saved the same path at the same instant, and the database won the race.
|
|
91
|
+
*/
|
|
92
|
+
function describeDatabaseError(
|
|
93
|
+
error: { code?: string | null; message?: string | null } | null,
|
|
94
|
+
fallback: string
|
|
95
|
+
): string {
|
|
96
|
+
if (!error) {
|
|
97
|
+
return fallback;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isSchemaMissingError(error)) {
|
|
101
|
+
return 'The redirects table does not exist yet. Run `npm run db:migrate` to apply the pending migrations, then reload this page.';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (error.code === '23505') {
|
|
105
|
+
return 'Another redirect already handles that source path. Reload the page to see it.';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return `${fallback} ${error.message ?? ''}`.trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The rule set every validation runs against, including paused rules.
|
|
113
|
+
*
|
|
114
|
+
* Paused rules count deliberately: `validateRedirectRule` uses this list for both the
|
|
115
|
+
* duplicate check and the loop walk, and excluding a paused rule would let an operator
|
|
116
|
+
* save a second rule for the same source, then re-enable the first and end up with two
|
|
117
|
+
* rules fighting over one path.
|
|
118
|
+
*/
|
|
119
|
+
async function loadRedirectRules(
|
|
120
|
+
supabase: NonNullable<Awaited<ReturnType<typeof requireAdminOrMessage>>['supabase']>
|
|
121
|
+
): Promise<RedirectRule[]> {
|
|
122
|
+
const { data } = await supabase.from(CMS_REDIRECTS_TABLE).select(REDIRECT_ADMIN_COLUMNS);
|
|
123
|
+
|
|
124
|
+
return toRedirectRules((data ?? []) as CmsRedirect[]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Shape and bounds only.
|
|
129
|
+
*
|
|
130
|
+
* The interesting rules — blank source, a full URL in the source box, a self-redirect,
|
|
131
|
+
* a duplicate, a cycle — belong to `validateRedirectRule` in the pure engine, which the
|
|
132
|
+
* browser runs too, so the operator sees the same sentence before and after saving.
|
|
133
|
+
* Zod's job here is narrower: reject a payload that is not the right *kind* of thing,
|
|
134
|
+
* which is what stops a hand-rolled fetch to this action from writing a 500-kilobyte
|
|
135
|
+
* path or a status code the table's CHECK constraint would reject as an opaque error.
|
|
136
|
+
*/
|
|
137
|
+
const redirectInputSchema = z.object({
|
|
138
|
+
destinationPath: z.string().max(2048),
|
|
139
|
+
isActive: z.boolean(),
|
|
140
|
+
sourcePath: z.string().max(2048),
|
|
141
|
+
statusCode: z.union([z.literal(301), z.literal(302)]),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const redirectIdSchema = z.string().uuid();
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The robots payload, bounded so a malicious or buggy client cannot park an unbounded
|
|
148
|
+
* blob in a row that is read on every `/robots.txt` request. Everything semantic is
|
|
149
|
+
* left to `normalizeRobotsSettings`, which never throws and always returns a complete
|
|
150
|
+
* object — the property that keeps a garbage settings row from turning robots.txt into
|
|
151
|
+
* a 500, which some crawlers read as "the whole site is disallowed".
|
|
152
|
+
*/
|
|
153
|
+
const robotsSettingsSchema = z.object({
|
|
154
|
+
customRules: z.string().max(20000),
|
|
155
|
+
isIndexingEnabled: z.boolean(),
|
|
156
|
+
sitemapEnabled: z.boolean(),
|
|
157
|
+
userAgentRules: z
|
|
158
|
+
.array(
|
|
159
|
+
z.object({
|
|
160
|
+
allow: z.array(z.string().max(2048)).max(200),
|
|
161
|
+
disallow: z.array(z.string().max(2048)).max(200),
|
|
162
|
+
userAgent: z.string().max(200),
|
|
163
|
+
})
|
|
164
|
+
)
|
|
165
|
+
.max(50),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Every redirect rule, active or not, ordered the way an operator reads them.
|
|
170
|
+
*
|
|
171
|
+
* Ordered by `source_path` rather than by creation date because this list is used as a
|
|
172
|
+
* lookup ("is /old-pricing already handled?") far more often than as a history, and an
|
|
173
|
+
* alphabetical list answers that question by eye.
|
|
174
|
+
*
|
|
175
|
+
* Throws for a non-admin — it is called only by the Server Component below, which is
|
|
176
|
+
* itself gated, so a throw here lands on the error boundary rather than in front of a
|
|
177
|
+
* user. A *read* failure is different and does not throw: an install that has not run
|
|
178
|
+
* its migrations yet would otherwise get an error page instead of the screen that
|
|
179
|
+
* explains what to do, so the failure is logged and reported as an empty list.
|
|
180
|
+
*/
|
|
181
|
+
export async function getRedirects(): Promise<CmsRedirect[]> {
|
|
182
|
+
const { supabase } = await requireAdminSupabaseClient();
|
|
183
|
+
|
|
184
|
+
const { data, error } = await supabase
|
|
185
|
+
.from(CMS_REDIRECTS_TABLE)
|
|
186
|
+
.select(REDIRECT_ADMIN_COLUMNS)
|
|
187
|
+
.order('source_path');
|
|
188
|
+
|
|
189
|
+
if (error) {
|
|
190
|
+
console.error('SEO settings: could not read redirects —', describeDatabaseError(error, 'Read failed.'));
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return (data ?? []) as CmsRedirect[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The stored robots directives, in canonical form.
|
|
199
|
+
*
|
|
200
|
+
* The raw jsonb goes through `normalizeRobotsSettings` on the way out, which is what
|
|
201
|
+
* makes a missing row, a row left as SQL `null`, and a row an older release wrote in a
|
|
202
|
+
* different shape all resolve to the same complete object instead of throwing. The
|
|
203
|
+
* migration seeds this key, but an install that predates it — or one whose row was
|
|
204
|
+
* edited by hand — must still be able to open this screen.
|
|
205
|
+
*/
|
|
206
|
+
export async function getRobotsSettings(): Promise<RobotsSettings> {
|
|
207
|
+
const { supabase } = await requireAdminSupabaseClient();
|
|
208
|
+
|
|
209
|
+
const { data, error } = await supabase
|
|
210
|
+
.from('site_settings')
|
|
211
|
+
.select('value')
|
|
212
|
+
.eq('key', ROBOTS_SETTINGS_KEY)
|
|
213
|
+
.maybeSingle();
|
|
214
|
+
|
|
215
|
+
if (error) {
|
|
216
|
+
console.error('SEO settings: could not read robots settings —', error.message);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return normalizeRobotsSettings((data as { value?: unknown } | null)?.value);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function createRedirect(input: RedirectInput): Promise<SettingsActionResult> {
|
|
223
|
+
const { error: authError, supabase } = await requireAdminOrMessage();
|
|
224
|
+
if (authError !== null) {
|
|
225
|
+
return { ok: false, error: authError };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const parsed = redirectInputSchema.safeParse(input);
|
|
229
|
+
if (!parsed.success) {
|
|
230
|
+
return { ok: false, error: 'That redirect could not be read. Check the paths and try again.' };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Normalising before validating is what makes the admin screen and the proxy agree:
|
|
234
|
+
// both compare `normalizeRedirectPath` output, so a rule that looks unique here
|
|
235
|
+
// cannot turn out to collide at request time.
|
|
236
|
+
//
|
|
237
|
+
// The destination goes through the same function, and must. It was previously only
|
|
238
|
+
// trimmed, on the assumption that normalising would corrupt an off-site https://
|
|
239
|
+
// target — but `normalizeRedirectPath` passes an absolute URL through untouched
|
|
240
|
+
// apart from folding its scheme to lowercase, and that fold is precisely what is
|
|
241
|
+
// needed here: the table's CHECK is `destination_path ~ '^https://'`, a
|
|
242
|
+
// case-SENSITIVE match, so a destination typed as `HTTPS://example.com` passes
|
|
243
|
+
// validation and is then rejected by Postgres with a raw constraint-violation
|
|
244
|
+
// message the operator cannot act on.
|
|
245
|
+
const sourcePath = normalizeRedirectPath(parsed.data.sourcePath);
|
|
246
|
+
const destinationPath = normalizeRedirectPath(parsed.data.destinationPath);
|
|
247
|
+
|
|
248
|
+
const existingRules = await loadRedirectRules(supabase);
|
|
249
|
+
const validation = validateRedirectRule({ destinationPath, sourcePath }, existingRules);
|
|
250
|
+
if (!validation.ok) {
|
|
251
|
+
// Surfaced verbatim: every string in the engine is written to be read by the
|
|
252
|
+
// operator who typed the offending value, and paraphrasing it here would make the
|
|
253
|
+
// browser's live warning and the server's refusal disagree about the same rule.
|
|
254
|
+
return { ok: false, error: validation.error };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const { error } = await supabase.from(CMS_REDIRECTS_TABLE).insert({
|
|
258
|
+
destination_path: destinationPath,
|
|
259
|
+
is_active: parsed.data.isActive,
|
|
260
|
+
source_path: sourcePath,
|
|
261
|
+
status_code: parsed.data.statusCode,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (error) {
|
|
265
|
+
return { ok: false, error: describeDatabaseError(error, 'Failed to create the redirect.') };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
revalidateSeoSettings();
|
|
269
|
+
return { ok: true, message: `Redirect saved. ${sourcePath} now points to ${destinationPath}.` };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function updateRedirect(
|
|
273
|
+
id: string,
|
|
274
|
+
input: RedirectInput
|
|
275
|
+
): Promise<SettingsActionResult> {
|
|
276
|
+
const { error: authError, supabase } = await requireAdminOrMessage();
|
|
277
|
+
if (authError !== null) {
|
|
278
|
+
return { ok: false, error: authError };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const parsedId = redirectIdSchema.safeParse(id);
|
|
282
|
+
if (!parsedId.success) {
|
|
283
|
+
return { ok: false, error: 'That redirect no longer exists. Reload the page and try again.' };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const parsed = redirectInputSchema.safeParse(input);
|
|
287
|
+
if (!parsed.success) {
|
|
288
|
+
return { ok: false, error: 'That redirect could not be read. Check the paths and try again.' };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Both sides normalised, for the reasons spelled out in `createRedirect` — an
|
|
292
|
+
// edit that only changed a destination's scheme casing would otherwise fail the
|
|
293
|
+
// table CHECK with a raw Postgres error.
|
|
294
|
+
const sourcePath = normalizeRedirectPath(parsed.data.sourcePath);
|
|
295
|
+
const destinationPath = normalizeRedirectPath(parsed.data.destinationPath);
|
|
296
|
+
|
|
297
|
+
const existingRules = await loadRedirectRules(supabase);
|
|
298
|
+
// Passing the id is how editing in place works: the rule being edited is excluded
|
|
299
|
+
// from both the duplicate check and the loop walk, so a rule is allowed to keep its
|
|
300
|
+
// own source path.
|
|
301
|
+
const validation = validateRedirectRule(
|
|
302
|
+
{ destinationPath, id: parsedId.data, sourcePath },
|
|
303
|
+
existingRules
|
|
304
|
+
);
|
|
305
|
+
if (!validation.ok) {
|
|
306
|
+
return { ok: false, error: validation.error };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const { error } = await supabase
|
|
310
|
+
.from(CMS_REDIRECTS_TABLE)
|
|
311
|
+
.update({
|
|
312
|
+
destination_path: destinationPath,
|
|
313
|
+
is_active: parsed.data.isActive,
|
|
314
|
+
source_path: sourcePath,
|
|
315
|
+
status_code: parsed.data.statusCode,
|
|
316
|
+
})
|
|
317
|
+
.eq('id', parsedId.data);
|
|
318
|
+
|
|
319
|
+
if (error) {
|
|
320
|
+
return { ok: false, error: describeDatabaseError(error, 'Failed to update the redirect.') };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
revalidateSeoSettings();
|
|
324
|
+
return { ok: true, message: 'Redirect updated.' };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function deleteRedirect(id: string): Promise<SettingsActionResult> {
|
|
328
|
+
const { error: authError, supabase } = await requireAdminOrMessage();
|
|
329
|
+
if (authError !== null) {
|
|
330
|
+
return { ok: false, error: authError };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const parsedId = redirectIdSchema.safeParse(id);
|
|
334
|
+
if (!parsedId.success) {
|
|
335
|
+
return { ok: false, error: 'That redirect no longer exists. Reload the page and try again.' };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const { error } = await supabase.from(CMS_REDIRECTS_TABLE).delete().eq('id', parsedId.data);
|
|
339
|
+
|
|
340
|
+
if (error) {
|
|
341
|
+
return { ok: false, error: describeDatabaseError(error, 'Failed to delete the redirect.') };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
revalidateSeoSettings();
|
|
345
|
+
return {
|
|
346
|
+
ok: true,
|
|
347
|
+
message: 'Redirect deleted. Visitors asking for that path will get a 404 again.',
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Pauses or resumes one rule.
|
|
353
|
+
*
|
|
354
|
+
* Re-validating on the way *back on* is not redundant. The duplicate and loop checks
|
|
355
|
+
* deliberately count paused rules, so a rule that was valid when it was saved is still
|
|
356
|
+
* valid when it is resumed — but rows that predate this screen (an import, a direct SQL
|
|
357
|
+
* insert) were never checked at all, and resuming one is exactly when a conflict would
|
|
358
|
+
* first reach a visitor. Switching a rule *off* is never blocked: turning something off
|
|
359
|
+
* cannot make the site worse, and refusing to would leave an operator stuck with a rule
|
|
360
|
+
* they can neither fix nor stop.
|
|
361
|
+
*/
|
|
362
|
+
export async function toggleRedirect(id: string, isActive: boolean): Promise<SettingsActionResult> {
|
|
363
|
+
const { error: authError, supabase } = await requireAdminOrMessage();
|
|
364
|
+
if (authError !== null) {
|
|
365
|
+
return { ok: false, error: authError };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const parsedId = redirectIdSchema.safeParse(id);
|
|
369
|
+
if (!parsedId.success) {
|
|
370
|
+
return { ok: false, error: 'That redirect no longer exists. Reload the page and try again.' };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (isActive) {
|
|
374
|
+
const existingRules = await loadRedirectRules(supabase);
|
|
375
|
+
const target = existingRules.find((rule) => rule.id === parsedId.data);
|
|
376
|
+
|
|
377
|
+
if (!target) {
|
|
378
|
+
return { ok: false, error: 'That redirect no longer exists. Reload the page and try again.' };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const validation = validateRedirectRule(
|
|
382
|
+
{ destinationPath: target.destinationPath, id: target.id, sourcePath: target.sourcePath },
|
|
383
|
+
existingRules
|
|
384
|
+
);
|
|
385
|
+
if (!validation.ok) {
|
|
386
|
+
return { ok: false, error: validation.error };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const { error } = await supabase
|
|
391
|
+
.from(CMS_REDIRECTS_TABLE)
|
|
392
|
+
.update({ is_active: isActive })
|
|
393
|
+
.eq('id', parsedId.data);
|
|
394
|
+
|
|
395
|
+
if (error) {
|
|
396
|
+
return { ok: false, error: describeDatabaseError(error, 'Failed to change the redirect.') };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
revalidateSeoSettings();
|
|
400
|
+
return { ok: true, message: isActive ? 'Redirect enabled.' : 'Redirect paused.' };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Replaces the stored robots directives wholesale.
|
|
405
|
+
*
|
|
406
|
+
* The payload is normalised before it is written, not only after it is read, so the row
|
|
407
|
+
* on disk is already canonical: paths gain their leading slash, blank entries are
|
|
408
|
+
* dropped, and a rule with no user agent — which would render a `User-agent:` line
|
|
409
|
+
* applying to nobody and silently orphan every directive under it — is removed. Writing
|
|
410
|
+
* the canonical form means the file an operator previewed is the file that gets served,
|
|
411
|
+
* and it means anything else reading this row later (an export, an MCP tool, a future
|
|
412
|
+
* `app/robots.ts`) does not have to repeat the cleanup.
|
|
413
|
+
*/
|
|
414
|
+
export async function saveRobotsSettings(settings: RobotsSettings): Promise<SettingsActionResult> {
|
|
415
|
+
const { error: authError, supabase } = await requireAdminOrMessage();
|
|
416
|
+
if (authError !== null) {
|
|
417
|
+
return { ok: false, error: authError };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const parsed = robotsSettingsSchema.safeParse(settings);
|
|
421
|
+
if (!parsed.success) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
error: 'Those robots settings could not be read. Shorten any very long rule list and try again.',
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const normalized = normalizeRobotsSettings(parsed.data);
|
|
429
|
+
|
|
430
|
+
const { error } = await supabase
|
|
431
|
+
.from('site_settings')
|
|
432
|
+
.upsert({ key: ROBOTS_SETTINGS_KEY, value: normalized });
|
|
433
|
+
|
|
434
|
+
if (error) {
|
|
435
|
+
return {
|
|
436
|
+
ok: false,
|
|
437
|
+
error: describeDatabaseError(error, 'Failed to save the robots settings.'),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
revalidateRobotsFile();
|
|
442
|
+
return {
|
|
443
|
+
ok: true,
|
|
444
|
+
message: normalized.isIndexingEnabled
|
|
445
|
+
? 'Robots settings saved.'
|
|
446
|
+
: 'Robots settings saved. Search engines are now asked not to index this site.',
|
|
447
|
+
};
|
|
448
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The snake_case ↔ camelCase boundary for managed redirects.
|
|
3
|
+
*
|
|
4
|
+
* The database speaks `source_path` / `is_active`; the pure SEO engine in
|
|
5
|
+
* `@nextblock-cms/utils/seo` speaks `sourcePath` / `isActive`. Exactly one place is
|
|
6
|
+
* allowed to know both vocabularies, and this is it — every other module on this
|
|
7
|
+
* screen sees one shape or the other and never has to guess which.
|
|
8
|
+
*
|
|
9
|
+
* These helpers are pure and live outside `actions.ts` deliberately: that file is
|
|
10
|
+
* `'use server'`, where every export must be an async server action, so a synchronous
|
|
11
|
+
* mapper cannot be exported from there. Keeping them here also means the client
|
|
12
|
+
* components can use the identical mapping the actions use, which is what lets the
|
|
13
|
+
* browser run the real `validateRedirectRule` against the real rule set before a
|
|
14
|
+
* request is ever made.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { RedirectRule, RedirectStatusCode } from '@nextblock-cms/utils/seo';
|
|
18
|
+
|
|
19
|
+
import { mapRedirectRow, type CmsRedirectRow } from '../../../../lib/seo/redirect-store';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A row of `public.cms_redirects` as this screen sees it.
|
|
23
|
+
*
|
|
24
|
+
* WHY IT IS AN ALIAS AND NOT A SECOND HAND-WRITTEN INTERFACE. The row shape was
|
|
25
|
+
* already transcribed from migration 00000000000030's DDL in `lib/seo/redirect-store.ts`,
|
|
26
|
+
* for the proxy's benefit, and for the same reason it had to be hand-written there:
|
|
27
|
+
* `libs/db`'s generated `Database` type comes from `npm run db:types` against a live
|
|
28
|
+
* project, that migration has not been applied yet, and so
|
|
29
|
+
* `Database['public']['Tables']['cms_redirects']` does not compile today. Restating
|
|
30
|
+
* the seven columns here would give the repo two hand-written descriptions of one
|
|
31
|
+
* table that could drift apart in a rename; aliasing gives it one. When the migration
|
|
32
|
+
* lands and the types are regenerated, there is a single interface to delete.
|
|
33
|
+
*/
|
|
34
|
+
export type CmsRedirect = CmsRedirectRow;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What the add/edit form collects, in the engine's vocabulary.
|
|
38
|
+
*
|
|
39
|
+
* `statusCode` is `RedirectStatusCode` rather than `number` so a widened value cannot
|
|
40
|
+
* reach the database and trip the table's CHECK constraint as an opaque Postgres
|
|
41
|
+
* error; the server re-validates it with Zod regardless, because a type says nothing
|
|
42
|
+
* about what actually arrived over the wire.
|
|
43
|
+
*/
|
|
44
|
+
export interface RedirectInput {
|
|
45
|
+
destinationPath: string;
|
|
46
|
+
isActive: boolean;
|
|
47
|
+
sourcePath: string;
|
|
48
|
+
statusCode: RedirectStatusCode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Maps stored rows onto the engine's rule shape, dropping any row too malformed to
|
|
53
|
+
* be a rule at all.
|
|
54
|
+
*
|
|
55
|
+
* Reuses `mapRedirectRow`, the proxy's mapper, so the admin screen's idea of the
|
|
56
|
+
* current rule set is byte-for-byte the proxy's idea of it. That agreement is what
|
|
57
|
+
* makes client-side validation trustworthy: a duplicate or a loop the admin screen
|
|
58
|
+
* refuses is a duplicate or a loop the proxy would actually have hit.
|
|
59
|
+
*
|
|
60
|
+
* Unlike the proxy's query this one is given inactive rows too, and that is
|
|
61
|
+
* intentional — `validateRedirectRule` counts a paused rule when checking for
|
|
62
|
+
* duplicates and loops, so re-enabling one cannot introduce a conflict that
|
|
63
|
+
* validation already waved through.
|
|
64
|
+
*/
|
|
65
|
+
export function toRedirectRules(rows: CmsRedirect[]): RedirectRule[] {
|
|
66
|
+
const rules: RedirectRule[] = [];
|
|
67
|
+
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
const rule = mapRedirectRow(row);
|
|
70
|
+
if (rule !== null) {
|
|
71
|
+
rules.push(rule);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return rules;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Maps one stored row onto the form's input shape, for the inline editor.
|
|
80
|
+
*
|
|
81
|
+
* Falls back to 301 for an out-of-range `status_code` for the same reason
|
|
82
|
+
* `mapRedirectRow` does: the column is constrained to 301/302, so the fallback never
|
|
83
|
+
* fires in practice, and defaulting to permanent keeps a rule the operator can see in
|
|
84
|
+
* the list from silently becoming un-editable.
|
|
85
|
+
*/
|
|
86
|
+
export function toRedirectInput(row: CmsRedirect): RedirectInput {
|
|
87
|
+
return {
|
|
88
|
+
destinationPath: row.destination_path,
|
|
89
|
+
isActive: row.is_active,
|
|
90
|
+
sourcePath: row.source_path,
|
|
91
|
+
statusCode: row.status_code === 302 ? 302 : 301,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// app/cms/settings/seo/page.tsx
|
|
2
|
+
import { hasResolvedSiteUrl, resolveSiteUrl } from '../../../../lib/site-url';
|
|
3
|
+
import { getRedirects, getRobotsSettings } from './actions';
|
|
4
|
+
import { requireAdminSupabaseClient } from './require-admin';
|
|
5
|
+
import { SeoSettingsClient } from './SeoSettingsClient';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The SEO operations screen: which URLs moved, and what crawlers are allowed to do.
|
|
9
|
+
*
|
|
10
|
+
* ADMIN-only, and gated here as well as in every action and in RLS. The gate is
|
|
11
|
+
* repeated rather than delegated because the three layers protect different things:
|
|
12
|
+
* this one stops a WRITER from ever seeing the screen (a redirect list is a map of a
|
|
13
|
+
* site's history, and the robots block is a map of what it is hiding), the actions
|
|
14
|
+
* stop a hand-rolled request that never loaded the page, and RLS stops everything
|
|
15
|
+
* else. `requireAdminSupabaseClient` throws, which is the right behaviour for a
|
|
16
|
+
* Server Component: an unauthorised visitor gets the error boundary instead of a
|
|
17
|
+
* half-rendered admin screen.
|
|
18
|
+
*
|
|
19
|
+
* The two reads run concurrently because they are independent — one hits
|
|
20
|
+
* `cms_redirects`, the other one row of `site_settings` — and running them in series
|
|
21
|
+
* would add a whole round trip to a screen that is already behind an auth check.
|
|
22
|
+
*/
|
|
23
|
+
export default async function SeoSettingsPage() {
|
|
24
|
+
await requireAdminSupabaseClient();
|
|
25
|
+
|
|
26
|
+
const [redirects, robotsSettings] = await Promise.all([getRedirects(), getRobotsSettings()]);
|
|
27
|
+
|
|
28
|
+
// Resolved on the server, where the deployment's env actually lives, and handed to
|
|
29
|
+
// the preview as data. The client component must never try to work this out for
|
|
30
|
+
// itself: `NEXT_PUBLIC_URL` is inlined at build time and the Vercel fallbacks are
|
|
31
|
+
// server-only, so a browser-side guess would quietly show a different sitemap URL
|
|
32
|
+
// from the one crawlers are given.
|
|
33
|
+
const siteUrl = resolveSiteUrl();
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="mx-auto max-w-5xl space-y-6">
|
|
37
|
+
<SeoSettingsClient
|
|
38
|
+
initialRedirects={redirects}
|
|
39
|
+
initialRobotsSettings={robotsSettings}
|
|
40
|
+
isSandbox={process.env.NEXT_PUBLIC_IS_SANDBOX === 'true'}
|
|
41
|
+
isSiteUrlConfigured={hasResolvedSiteUrl()}
|
|
42
|
+
sitemapUrl={`${siteUrl}/sitemap.xml`}
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createClient } from '@nextblock-cms/db/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Admin gate shared by the SEO settings server actions and by the page itself.
|
|
5
|
+
*
|
|
6
|
+
* Lives outside `actions.ts` because that file is `'use server'`, where every export
|
|
7
|
+
* must itself be a valid server action — a helper that returns a Supabase client
|
|
8
|
+
* cannot be exported from there. Keeping one copy matters more than the file count:
|
|
9
|
+
* this is the check standing between a WRITER and the redirect table, and a redirect
|
|
10
|
+
* rule is an open-redirect surface. Anyone who can write one can point every visitor
|
|
11
|
+
* of `/checkout` at a phishing clone of the site, with the real domain in the
|
|
12
|
+
* referrer, without touching a single page or post. The same reasoning is why
|
|
13
|
+
* migration 00000000000030 grants writes on `cms_redirects` to ADMIN only and leaves
|
|
14
|
+
* WRITER out entirely — the RLS policy is the boundary, and this is the friendly
|
|
15
|
+
* error that keeps an operator from meeting it as an unexplained empty list.
|
|
16
|
+
*
|
|
17
|
+
* It throws rather than returning a result because its two kinds of caller want
|
|
18
|
+
* opposite things. A Server Component wants the throw: an unauthorised visitor gets
|
|
19
|
+
* the error boundary instead of a half-rendered admin screen. A server action called
|
|
20
|
+
* from a client component wants a message it can render, so those callers catch the
|
|
21
|
+
* throw and return it as data — see `requireAdminOrMessage` in `actions.ts` and the
|
|
22
|
+
* note on `SettingsActionResult` about Next replacing uncaught Server Action error
|
|
23
|
+
* messages with a generic string in production.
|
|
24
|
+
*/
|
|
25
|
+
export async function requireAdminSupabaseClient() {
|
|
26
|
+
const supabase = createClient();
|
|
27
|
+
const {
|
|
28
|
+
data: { user },
|
|
29
|
+
error: userError,
|
|
30
|
+
} = await supabase.auth.getUser();
|
|
31
|
+
|
|
32
|
+
if (userError || !user) {
|
|
33
|
+
throw new Error('You must be logged in to manage SEO settings.');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { data: profile, error: profileError } = await supabase
|
|
37
|
+
.from('profiles')
|
|
38
|
+
.select('role')
|
|
39
|
+
.eq('id', user.id)
|
|
40
|
+
.single();
|
|
41
|
+
|
|
42
|
+
if (profileError || !profile || profile.role !== 'ADMIN') {
|
|
43
|
+
throw new Error('You do not have permission to manage SEO settings.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { supabase, userId: user.id };
|
|
47
|
+
}
|