create-nextblock 0.16.1 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/docker-template/.dockerignore +1 -1
  2. package/libs/db/tsconfig.lib.json +3 -3
  3. package/libs/editor/tsconfig.lib.json +3 -3
  4. package/libs/ui/tsconfig.lib.json +3 -3
  5. package/libs/utils/tsconfig.json +3 -3
  6. package/package.json +50 -50
  7. package/project.json +19 -19
  8. package/templates/nextblock-template/.dockerignore +1 -1
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/AGENTS.md +9 -9
  11. package/templates/nextblock-template/CLAUDE.md +1 -1
  12. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  13. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  14. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  15. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
  16. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  17. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
  18. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
  19. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
  20. package/templates/nextblock-template/app/actions/email.ts +241 -241
  21. package/templates/nextblock-template/app/actions/formActions.ts +245 -245
  22. package/templates/nextblock-template/app/actions/interactions.ts +489 -489
  23. package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
  24. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
  25. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -221
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +4 -4
  30. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4542 -2904
  31. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  32. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  33. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  34. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  35. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  37. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  38. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  39. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  40. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  41. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
  42. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  43. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  44. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  45. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
  46. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  47. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  48. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  49. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  50. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  51. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  52. package/templates/nextblock-template/app/cms/components/SeoScoreBadge.tsx +40 -0
  53. package/templates/nextblock-template/app/cms/components/TablePagination.tsx +136 -0
  54. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  55. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  56. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  58. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  59. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  60. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  61. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  63. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  64. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  65. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  66. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  67. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  68. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  69. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  70. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  71. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  72. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  73. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
  74. package/templates/nextblock-template/app/cms/pages/page.tsx +273 -227
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +2 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +624 -618
  77. package/templates/nextblock-template/app/cms/posts/page.tsx +251 -194
  78. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  79. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  80. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  81. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  82. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  83. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  84. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  85. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  86. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  90. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  91. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  92. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  93. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  96. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  98. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  99. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  100. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  101. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  102. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  103. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  104. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  105. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  106. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  107. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  108. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  109. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  110. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  111. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  112. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  113. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  114. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  115. package/templates/nextblock-template/app/layout.tsx +671 -671
  116. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  117. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  118. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  119. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  120. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  121. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  122. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  123. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  124. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  125. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  126. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  127. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  128. package/templates/nextblock-template/app/providers.tsx +96 -96
  129. package/templates/nextblock-template/app/robots.ts +123 -123
  130. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  131. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  132. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  133. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  134. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  135. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  136. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  137. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  138. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  139. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  140. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  141. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  142. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  143. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  144. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  145. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  146. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  147. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  148. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  149. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  150. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  151. package/templates/nextblock-template/components/Header.tsx +38 -38
  152. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  153. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  154. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  155. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  156. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  157. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  158. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  159. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  160. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  161. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  162. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  163. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  164. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  165. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  166. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  167. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  168. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  169. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  170. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  171. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  172. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  173. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  174. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  175. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  176. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  177. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  178. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  179. package/templates/nextblock-template/components/form-message.tsx +32 -32
  180. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +251 -244
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  187. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  188. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  189. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  190. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  191. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  192. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  193. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  194. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  195. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  196. package/templates/nextblock-template/docs/README.md +42 -42
  197. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  198. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  199. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  200. package/templates/nextblock-template/index.d.ts +7 -7
  201. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  202. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  203. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  204. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  205. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  206. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  207. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  208. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  209. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  210. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  211. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  212. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  213. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  215. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  216. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  217. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  218. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  219. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  220. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  221. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  222. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  223. package/templates/nextblock-template/lib/search/types.ts +27 -27
  224. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +157 -140
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +384 -350
  231. package/templates/nextblock-template/lib/seo/page-document.ts +463 -412
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +37 -22
  239. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  240. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  241. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  242. package/templates/nextblock-template/lib/site-url.ts +102 -102
  243. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  244. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  245. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  246. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  247. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  248. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  249. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  250. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  251. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  252. package/templates/nextblock-template/next-env.d.ts +1 -2
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/scripts/backup.js +115 -115
  256. package/templates/nextblock-template/scripts/restore.js +385 -385
  257. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  258. package/templates/nextblock-template/tailwind.config.js +25 -25
  259. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  260. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  261. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  262. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  263. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  264. package/tsconfig.base.json +3 -3
@@ -1,448 +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
- }
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
+ }