create-nextblock 0.16.1 → 0.16.2

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