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,529 +1,529 @@
1
- 'use client';
2
-
3
- import React, { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react';
4
- import { useRouter } from 'next/navigation';
5
- import {
6
- Button,
7
- Card,
8
- CardContent,
9
- CardDescription,
10
- CardHeader,
11
- CardTitle,
12
- Checkbox,
13
- Input,
14
- Label,
15
- Separator,
16
- } from '@nextblock-cms/ui';
17
- import {
18
- formatPathList,
19
- parsePathList,
20
- type RobotsSettings,
21
- type RobotsUserAgentRule,
22
- } from '@nextblock-cms/utils/seo';
23
- import { AlertTriangle, Info, Plus, Save, Trash2 } from 'lucide-react';
24
- import { toast } from 'sonner';
25
-
26
- import { buildRobotsTxt, listUnservedCustomRuleLines } from '../../../../lib/seo/robots-txt';
27
- import { robotsSettingsSignature } from '../../../../lib/seo/robots-settings-signature';
28
- import { saveRobotsSettings } from './actions';
29
-
30
- /** Matches the textarea styling used by the other settings screens. */
31
- const textareaClass =
32
- 'flex min-h-[92px] w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
33
-
34
- /**
35
- * One user-agent block as the form holds it.
36
- *
37
- * The two path lists are kept as raw textarea TEXT rather than as parsed arrays, and
38
- * that is the whole reason this type exists alongside `RobotsUserAgentRule`. Parsing
39
- * on every keystroke would fight the operator: `parsePathList` drops blank entries, so
40
- * the newline they just pressed would vanish before they could type the next path, and
41
- * a leading slash would appear under their cursor mid-word. The text is therefore
42
- * authoritative while the form is open and is parsed once, on the way out — which is
43
- * also exactly when the preview re-derives it, so what is previewed is what is saved.
44
- *
45
- * `key` is a render key, not data. Rules have no identity in the stored jsonb, so
46
- * removing the second of three rules would otherwise make React re-use the wrong DOM
47
- * nodes and move an operator's half-typed text into a different block.
48
- */
49
- interface DraftUserAgentRule {
50
- allowText: string;
51
- disallowText: string;
52
- key: number;
53
- userAgent: string;
54
- }
55
-
56
- let nextRuleKey = 0;
57
-
58
- function toDraftRules(rules: RobotsUserAgentRule[]): DraftUserAgentRule[] {
59
- return rules.map((rule) => ({
60
- allowText: formatPathList(rule.allow),
61
- disallowText: formatPathList(rule.disallow),
62
- key: (nextRuleKey += 1),
63
- userAgent: rule.userAgent,
64
- }));
65
- }
66
-
67
- type RobotsCardProps = {
68
- isSandbox: boolean;
69
- isSiteUrlConfigured: boolean;
70
- settings: RobotsSettings;
71
- sitemapUrl: string;
72
- };
73
-
74
- /**
75
- * The robots.txt editor, with a preview that cannot lie.
76
- *
77
- * The preview is rendered by `buildRobotsTxt` — the same function that serialises the
78
- * file crawlers are served. It is not a rendering "of the settings"; it is the file.
79
- * That matters more here than on most screens because robots.txt is write-only from
80
- * the operator's side: a mistake in it does not break a page they would notice, it
81
- * quietly removes the site from search results, and the feedback loop is weeks long.
82
- * Any second formatter written to "show roughly what will happen" would eventually
83
- * disagree with the real one, and the disagreement would be discovered by traffic
84
- * disappearing.
85
- *
86
- * Everything is edited locally and committed in one save. Robots directives are read
87
- * as a set — an `Allow` means nothing without knowing which `Disallow` it is carving
88
- * an exception out of — so saving each field as it changes would publish a sequence of
89
- * intermediate files that the operator never intended and never saw.
90
- */
91
- export function RobotsCard({
92
- isSandbox,
93
- isSiteUrlConfigured,
94
- settings,
95
- sitemapUrl,
96
- }: RobotsCardProps) {
97
- const router = useRouter();
98
- const [isPending, startTransition] = useTransition();
99
- const [customRules, setCustomRules] = useState(settings.customRules);
100
- const [isIndexingEnabled, setIsIndexingEnabled] = useState(settings.isIndexingEnabled);
101
- const [sitemapEnabled, setSitemapEnabled] = useState(settings.sitemapEnabled);
102
- const [draftRules, setDraftRules] = useState<DraftUserAgentRule[]>(() =>
103
- toDraftRules(settings.userAgentRules)
104
- );
105
-
106
- /**
107
- * Re-seed the form when the SERVER's settings genuinely change.
108
- *
109
- * The state above is seeded once, which was the whole bug: after a save the action
110
- * revalidates and the normalised row comes back down this prop — blank paths dropped,
111
- * missing leading slashes added — but the card kept its own copy. The Save button stayed
112
- * lit forever because `isDirty` was comparing the operator's draft against a value the
113
- * database no longer held, and the preview kept rendering custom rules that had been
114
- * tidied away server-side, i.e. showing a file nobody would ever be served.
115
- *
116
- * The reset is gated on the settings' CONTENT, not on the prop's identity. A server
117
- * component builds a fresh object on every render, so `settings` is a new reference after
118
- * every `router.refresh()` — including refreshes triggered by other cards on this screen.
119
- * An effect keyed on the object itself would therefore wipe out half-typed rules whenever
120
- * anything else revalidated. Comparing signatures means the state is only ever thrown
121
- * away when the stored value really moved, which is exactly the moment the local copy is
122
- * known to be obsolete.
123
- */
124
- const settingsSignature = useMemo(() => robotsSettingsSignature(settings), [settings]);
125
- const seededSignatureRef = useRef(settingsSignature);
126
-
127
- useEffect(() => {
128
- if (seededSignatureRef.current === settingsSignature) {
129
- return;
130
- }
131
-
132
- seededSignatureRef.current = settingsSignature;
133
- setCustomRules(settings.customRules);
134
- setIsIndexingEnabled(settings.isIndexingEnabled);
135
- setSitemapEnabled(settings.sitemapEnabled);
136
- // Fresh render keys are minted here, so the textareas remount rather than reusing
137
- // nodes across a rule list that may have changed length — the same identity problem
138
- // `DraftUserAgentRule.key` exists to solve.
139
- setDraftRules(toDraftRules(settings.userAgentRules));
140
- }, [settings, settingsSignature]);
141
-
142
- /**
143
- * The form's current value in the engine's shape.
144
- *
145
- * Derived rather than stored, so there is exactly one source of truth for what is
146
- * about to be saved — and the preview below and the payload sent to the server are
147
- * literally the same object.
148
- */
149
- const draftSettings: RobotsSettings = useMemo(
150
- () => ({
151
- customRules,
152
- isIndexingEnabled,
153
- sitemapEnabled,
154
- userAgentRules: draftRules.map((rule) => ({
155
- allow: parsePathList(rule.allowText),
156
- disallow: parsePathList(rule.disallowText),
157
- userAgent: rule.userAgent.trim(),
158
- })),
159
- }),
160
- [customRules, draftRules, isIndexingEnabled, sitemapEnabled]
161
- );
162
-
163
- /**
164
- * Whether anything has actually changed, which is what gates the Save button and
165
- * Ctrl+S.
166
- *
167
- * Compared through `robotsSettingsSignature`, which reads the fields explicitly. A raw
168
- * `JSON.stringify` of both sides worked only because the form's object literal and
169
- * `normalizeRobotsSettings`' object literal happen to list their keys in the same
170
- * (alphabetical, per the house convention) order; the day one of them was reordered the
171
- * form would have been born dirty with nothing pointing at why. The failure mode is
172
- * benign in one direction only — a false "dirty" costs a redundant write, a false
173
- * "clean" loses an edit — so this comparison is allowed to be conservative but must
174
- * never be clever enough to miss a real change.
175
- */
176
- const isDirty = useMemo(
177
- () => robotsSettingsSignature(draftSettings) !== settingsSignature,
178
- [draftSettings, settingsSignature]
179
- );
180
-
181
- // What crawlers would actually receive, produced by the public serialiser. In the
182
- // sandbox that answer is fixed, so the second preview shows what a real install
183
- // would serve from the same settings — otherwise a sandbox visitor edits the form,
184
- // watches the preview refuse to change, and concludes the screen is broken.
185
- const servedPreview = useMemo(
186
- () => buildRobotsTxt(draftSettings, { isSandbox, sitemapUrl }),
187
- [draftSettings, isSandbox, sitemapUrl]
188
- );
189
- const ownInstallPreview = useMemo(
190
- () => (isSandbox ? buildRobotsTxt(draftSettings, { isSandbox: false, sitemapUrl }) : null),
191
- [draftSettings, isSandbox, sitemapUrl]
192
- );
193
-
194
- // Lines from the "extra lines" box that the served file cannot carry — comments,
195
- // and anything without a `directive: value` shape. The public file is produced by
196
- // Next's metadata route, which emits only the directives it models, so a line that
197
- // survives in this component's text preview would not necessarily survive to a
198
- // crawler. Showing the difference is the whole point: a preview an operator trusts
199
- // and that quietly drops a directive is worse than no preview at all, because they
200
- // walk away believing a rule is live.
201
- const unservedCustomLines = useMemo(
202
- () => listUnservedCustomRuleLines(draftSettings, { isSandbox, sitemapUrl }),
203
- [draftSettings, isSandbox, sitemapUrl]
204
- );
205
-
206
- const save = useCallback(() => {
207
- startTransition(async () => {
208
- const result = await saveRobotsSettings(draftSettings);
209
-
210
- if (result.ok) {
211
- toast.success(result.message);
212
- // Pulls the canonical, normalised row back down — blank paths dropped, missing
213
- // leading slashes added — so the form stops showing a draft the database has
214
- // already tidied up.
215
- router.refresh();
216
- } else {
217
- toast.error(result.error);
218
- }
219
- });
220
- }, [draftSettings, router]);
221
-
222
- // Ctrl/Cmd+S saves, which is the reflex of anyone who has just edited a text file —
223
- // and this card is, to the operator, a text file. Hand-rolled rather than using a
224
- // `useHotkeys` hook because this workspace has no hotkey library; a listener on the
225
- // window is the whole of what such a hook would install anyway. The ref keeps the
226
- // handler stable so the listener is bound once instead of on every keystroke.
227
- const saveRef = useRef(save);
228
- saveRef.current = save;
229
- const canSaveRef = useRef(false);
230
- canSaveRef.current = isDirty && !isPending;
231
-
232
- useEffect(() => {
233
- const onKeyDown = (event: KeyboardEvent) => {
234
- if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') {
235
- return;
236
- }
237
-
238
- // Prevented unconditionally: the browser's "save this page" dialog is never what
239
- // somebody wants on a settings screen, and swallowing it while there is nothing
240
- // to save is less surprising than opening it.
241
- event.preventDefault();
242
-
243
- if (canSaveRef.current) {
244
- saveRef.current();
245
- }
246
- };
247
-
248
- window.addEventListener('keydown', onKeyDown);
249
- return () => window.removeEventListener('keydown', onKeyDown);
250
- }, []);
251
-
252
- const updateRule = (key: number, patch: Partial<DraftUserAgentRule>) => {
253
- setDraftRules((current) =>
254
- current.map((rule) => (rule.key === key ? { ...rule, ...patch } : rule))
255
- );
256
- };
257
-
258
- return (
259
- <Card>
260
- <CardHeader>
261
- <CardTitle>Search engines &amp; robots.txt</CardTitle>
262
- <CardDescription>
263
- <span className="font-mono">/robots.txt</span> is the first file a crawler asks for. It
264
- says which parts of the site may be fetched — it is a request, honoured by the major
265
- search engines and ignored by anything malicious, so it is a traffic-shaping tool and
266
- never a security control. Never list a private path here expecting it to be hidden:
267
- this file is public, and naming a URL in it is the most reliable way to advertise it.
268
- </CardDescription>
269
- </CardHeader>
270
-
271
- <CardContent className="space-y-6">
272
- {isSandbox && (
273
- <div className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
274
- <Info className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
275
- <div className="space-y-1">
276
- <p className="font-medium">This sandbox always serves the same robots.txt.</p>
277
- <p className="text-muted-foreground">
278
- The demo is a copy of a real site and must stay out of Google&apos;s index, which it
279
- does by allowing crawling and answering every request with a{' '}
280
- <span className="font-mono">noindex</span> header — a crawler can only obey a
281
- noindex it is allowed to fetch. Blocking the crawl would leave the demo&apos;s URLs
282
- stranded in the index instead. Edit freely: the second preview below shows what your
283
- own install would serve.
284
- </p>
285
- </div>
286
- </div>
287
- )}
288
-
289
- <div className="space-y-4">
290
- <label className="flex items-start gap-3 text-sm" htmlFor="robots-indexing">
291
- <Checkbox
292
- checked={isIndexingEnabled}
293
- className="mt-0.5"
294
- id="robots-indexing"
295
- onCheckedChange={(checked) => setIsIndexingEnabled(checked === true)}
296
- />
297
- <span>
298
- <span className="font-medium">Allow search engines to index this site</span>
299
- <span className="block text-muted-foreground">
300
- Turning this off replaces every rule below with a site-wide{' '}
301
- <span className="font-mono">Disallow: /</span>, asking every crawler to stay away
302
- from the entire site. Your rules are kept and reappear the moment you switch it back
303
- on. Use it for a site that is not ready to be public — and expect pages already in
304
- the index to take weeks to drop out.
305
- </span>
306
- </span>
307
- </label>
308
-
309
- <label className="flex items-start gap-3 text-sm" htmlFor="robots-sitemap">
310
- <Checkbox
311
- checked={sitemapEnabled}
312
- className="mt-0.5"
313
- id="robots-sitemap"
314
- onCheckedChange={(checked) => setSitemapEnabled(checked === true)}
315
- />
316
- <span>
317
- <span className="font-medium">Include a sitemap reference</span>
318
- <span className="block text-muted-foreground">
319
- Adds <span className="font-mono">Sitemap: {sitemapUrl}</span>, which is how a crawler
320
- discovers pages nothing links to. It is dropped automatically while indexing is off,
321
- because inviting a crawler to a sitemap you have just told it not to fetch is a
322
- contradiction.
323
- </span>
324
- </span>
325
- </label>
326
-
327
- {sitemapEnabled && !isSiteUrlConfigured && (
328
- <div className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
329
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
330
- <p>
331
- No public site URL is configured, so the sitemap line would point at{' '}
332
- <span className="font-mono">{sitemapUrl}</span> — an address no crawler can reach.
333
- Set <span className="font-mono">NEXT_PUBLIC_URL</span> for this deployment.
334
- </p>
335
- </div>
336
- )}
337
- </div>
338
-
339
- <Separator />
340
-
341
- <div className="space-y-4">
342
- <div>
343
- <h3 className="text-sm font-semibold">Crawler rules</h3>
344
- <p className="text-sm text-muted-foreground">
345
- One block per crawler. <span className="font-mono">*</span> means every crawler that
346
- has no block of its own. Put one path per line — a path is matched as a prefix, so{' '}
347
- <span className="font-mono">/admin</span> also covers{' '}
348
- <span className="font-mono">/admin/anything</span>.
349
- </p>
350
- </div>
351
-
352
- {draftRules.length === 0 && (
353
- <p className="text-sm text-muted-foreground">
354
- No blocks. A robots.txt with no rules at all is meaningless rather than strict, so one
355
- permissive <span className="font-mono">User-agent: *</span> block is served instead —
356
- see the preview.
357
- </p>
358
- )}
359
-
360
- {draftRules.map((rule, index) => (
361
- <div className="space-y-4 rounded-lg border bg-muted/30 p-4" key={rule.key}>
362
- <div className="flex items-end gap-3">
363
- <div className="flex-1 space-y-2">
364
- <Label htmlFor={`robots-agent-${rule.key}`}>Crawler (user agent)</Label>
365
- <Input
366
- className="font-mono"
367
- id={`robots-agent-${rule.key}`}
368
- onChange={(event) => updateRule(rule.key, { userAgent: event.target.value })}
369
- placeholder="*"
370
- spellCheck={false}
371
- value={rule.userAgent}
372
- />
373
- </div>
374
- <Button
375
- aria-label={`Remove the ${rule.userAgent || 'unnamed'} block`}
376
- onClick={() =>
377
- setDraftRules((current) => current.filter((entry) => entry.key !== rule.key))
378
- }
379
- size="sm"
380
- variant="outline"
381
- >
382
- <Trash2 className="h-4 w-4" />
383
- </Button>
384
- </div>
385
-
386
- {rule.userAgent.trim() === '' && (
387
- <p className="text-xs text-destructive">
388
- A block with no crawler name is dropped when you save — a bare{' '}
389
- <span className="font-mono">User-agent:</span> line would apply to nobody and
390
- silently orphan the rules under it.
391
- </p>
392
- )}
393
-
394
- <div className="grid gap-4 sm:grid-cols-2">
395
- <div className="space-y-2">
396
- <Label htmlFor={`robots-allow-${rule.key}`}>Allow</Label>
397
- <textarea
398
- className={textareaClass}
399
- id={`robots-allow-${rule.key}`}
400
- onChange={(event) => updateRule(rule.key, { allowText: event.target.value })}
401
- placeholder={'/'}
402
- spellCheck={false}
403
- value={rule.allowText}
404
- />
405
- </div>
406
- <div className="space-y-2">
407
- <Label htmlFor={`robots-disallow-${rule.key}`}>Disallow</Label>
408
- <textarea
409
- className={textareaClass}
410
- id={`robots-disallow-${rule.key}`}
411
- onChange={(event) => updateRule(rule.key, { disallowText: event.target.value })}
412
- placeholder={'/cms\n/api'}
413
- spellCheck={false}
414
- value={rule.disallowText}
415
- />
416
- </div>
417
- </div>
418
-
419
- {index === 0 && (
420
- <p className="text-xs text-muted-foreground">
421
- Paths are tidied on save: a missing leading slash is added and blank lines are
422
- dropped. A pattern starting with <span className="font-mono">*</span> is left
423
- alone, because <span className="font-mono">*.pdf$</span> would mean something else
424
- with a slash in front of it.
425
- </p>
426
- )}
427
- </div>
428
- ))}
429
-
430
- <Button
431
- onClick={() =>
432
- setDraftRules((current) => [
433
- ...current,
434
- { allowText: '', disallowText: '', key: (nextRuleKey += 1), userAgent: '' },
435
- ])
436
- }
437
- size="sm"
438
- variant="outline"
439
- >
440
- <Plus className="mr-2 h-4 w-4" />
441
- Add a crawler block
442
- </Button>
443
- </div>
444
-
445
- <Separator />
446
-
447
- <div className="space-y-2">
448
- <Label htmlFor="robots-custom">Extra lines</Label>
449
- <textarea
450
- className={`${textareaClass} min-h-[120px]`}
451
- id="robots-custom"
452
- onChange={(event) => setCustomRules(event.target.value)}
453
- placeholder={'# Anything valid in robots.txt\nUser-agent: GPTBot\nDisallow: /'}
454
- spellCheck={false}
455
- value={customRules}
456
- />
457
- <p className="text-xs text-muted-foreground">
458
- Appended to the file exactly as typed, after the blocks above and before the sitemap
459
- line. Use it for directives this form does not model — a{' '}
460
- <span className="font-mono">Crawl-delay</span>, a comment, an AI-crawler block. These
461
- lines survive the indexing switch, so an exception you need to keep working stays here.
462
- Nothing validates them; the preview is the check.
463
- </p>
464
- </div>
465
-
466
- <Separator />
467
-
468
- <div className="space-y-2">
469
- <h3 className="text-sm font-semibold">
470
- {isSandbox ? 'What this sandbox serves at /robots.txt' : 'What crawlers will receive'}
471
- </h3>
472
- <p className="text-sm text-muted-foreground">
473
- Generated by the same code that serves the file, so this is the file — not a
474
- description of it.
475
- </p>
476
- <pre className="overflow-x-auto rounded-md border bg-muted p-3 text-xs">
477
- <code>{servedPreview}</code>
478
- </pre>
479
-
480
- {unservedCustomLines.length > 0 && (
481
- <div className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
482
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
483
- <div>
484
- <p className="font-medium">
485
- {unservedCustomLines.length === 1
486
- ? 'One extra line will not appear in the served file:'
487
- : `${unservedCustomLines.length} extra lines will not appear in the served file:`}
488
- </p>
489
- <ul className="mt-2 space-y-1">
490
- {unservedCustomLines.map((line) => (
491
- <li key={line} className="font-mono text-xs">
492
- {line}
493
- </li>
494
- ))}
495
- </ul>
496
- <p className="mt-2 text-xs">
497
- {isSandbox
498
- ? 'Sandbox deployments serve a fixed robots.txt, so extra lines are inert here. They will apply on your own install.'
499
- : 'robots.txt is served through the Next.js metadata route, which emits only recognised directives — so comments and lines without a "Directive: value" shape are dropped. No crawler acts on those lines, so nothing is lost, but they will not be in the file.'}
500
- </p>
501
- </div>
502
- </div>
503
- )}
504
- </div>
505
-
506
- {ownInstallPreview !== null && (
507
- <div className="space-y-2">
508
- <h3 className="text-sm font-semibold">What your own install would serve</h3>
509
- <pre className="overflow-x-auto rounded-md border bg-muted p-3 text-xs">
510
- <code>{ownInstallPreview}</code>
511
- </pre>
512
- </div>
513
- )}
514
-
515
- <div className="flex items-center gap-3">
516
- <Button disabled={!isDirty || isPending} onClick={save}>
517
- <Save className="mr-2 h-4 w-4" />
518
- {isPending ? 'Saving…' : 'Save robots settings'}
519
- </Button>
520
- {isDirty && !isPending && (
521
- <span className="text-xs text-muted-foreground">
522
- Unsaved changes — Ctrl+S also saves.
523
- </span>
524
- )}
525
- </div>
526
- </CardContent>
527
- </Card>
528
- );
529
- }
1
+ 'use client';
2
+
3
+ import React, { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import {
6
+ Button,
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardHeader,
11
+ CardTitle,
12
+ Checkbox,
13
+ Input,
14
+ Label,
15
+ Separator,
16
+ } from '@nextblock-cms/ui';
17
+ import {
18
+ formatPathList,
19
+ parsePathList,
20
+ type RobotsSettings,
21
+ type RobotsUserAgentRule,
22
+ } from '@nextblock-cms/utils/seo';
23
+ import { AlertTriangle, Info, Plus, Save, Trash2 } from 'lucide-react';
24
+ import { toast } from 'sonner';
25
+
26
+ import { buildRobotsTxt, listUnservedCustomRuleLines } from '../../../../lib/seo/robots-txt';
27
+ import { robotsSettingsSignature } from '../../../../lib/seo/robots-settings-signature';
28
+ import { saveRobotsSettings } from './actions';
29
+
30
+ /** Matches the textarea styling used by the other settings screens. */
31
+ const textareaClass =
32
+ 'flex min-h-[92px] w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
33
+
34
+ /**
35
+ * One user-agent block as the form holds it.
36
+ *
37
+ * The two path lists are kept as raw textarea TEXT rather than as parsed arrays, and
38
+ * that is the whole reason this type exists alongside `RobotsUserAgentRule`. Parsing
39
+ * on every keystroke would fight the operator: `parsePathList` drops blank entries, so
40
+ * the newline they just pressed would vanish before they could type the next path, and
41
+ * a leading slash would appear under their cursor mid-word. The text is therefore
42
+ * authoritative while the form is open and is parsed once, on the way out — which is
43
+ * also exactly when the preview re-derives it, so what is previewed is what is saved.
44
+ *
45
+ * `key` is a render key, not data. Rules have no identity in the stored jsonb, so
46
+ * removing the second of three rules would otherwise make React re-use the wrong DOM
47
+ * nodes and move an operator's half-typed text into a different block.
48
+ */
49
+ interface DraftUserAgentRule {
50
+ allowText: string;
51
+ disallowText: string;
52
+ key: number;
53
+ userAgent: string;
54
+ }
55
+
56
+ let nextRuleKey = 0;
57
+
58
+ function toDraftRules(rules: RobotsUserAgentRule[]): DraftUserAgentRule[] {
59
+ return rules.map((rule) => ({
60
+ allowText: formatPathList(rule.allow),
61
+ disallowText: formatPathList(rule.disallow),
62
+ key: (nextRuleKey += 1),
63
+ userAgent: rule.userAgent,
64
+ }));
65
+ }
66
+
67
+ type RobotsCardProps = {
68
+ isSandbox: boolean;
69
+ isSiteUrlConfigured: boolean;
70
+ settings: RobotsSettings;
71
+ sitemapUrl: string;
72
+ };
73
+
74
+ /**
75
+ * The robots.txt editor, with a preview that cannot lie.
76
+ *
77
+ * The preview is rendered by `buildRobotsTxt` — the same function that serialises the
78
+ * file crawlers are served. It is not a rendering "of the settings"; it is the file.
79
+ * That matters more here than on most screens because robots.txt is write-only from
80
+ * the operator's side: a mistake in it does not break a page they would notice, it
81
+ * quietly removes the site from search results, and the feedback loop is weeks long.
82
+ * Any second formatter written to "show roughly what will happen" would eventually
83
+ * disagree with the real one, and the disagreement would be discovered by traffic
84
+ * disappearing.
85
+ *
86
+ * Everything is edited locally and committed in one save. Robots directives are read
87
+ * as a set — an `Allow` means nothing without knowing which `Disallow` it is carving
88
+ * an exception out of — so saving each field as it changes would publish a sequence of
89
+ * intermediate files that the operator never intended and never saw.
90
+ */
91
+ export function RobotsCard({
92
+ isSandbox,
93
+ isSiteUrlConfigured,
94
+ settings,
95
+ sitemapUrl,
96
+ }: RobotsCardProps) {
97
+ const router = useRouter();
98
+ const [isPending, startTransition] = useTransition();
99
+ const [customRules, setCustomRules] = useState(settings.customRules);
100
+ const [isIndexingEnabled, setIsIndexingEnabled] = useState(settings.isIndexingEnabled);
101
+ const [sitemapEnabled, setSitemapEnabled] = useState(settings.sitemapEnabled);
102
+ const [draftRules, setDraftRules] = useState<DraftUserAgentRule[]>(() =>
103
+ toDraftRules(settings.userAgentRules)
104
+ );
105
+
106
+ /**
107
+ * Re-seed the form when the SERVER's settings genuinely change.
108
+ *
109
+ * The state above is seeded once, which was the whole bug: after a save the action
110
+ * revalidates and the normalised row comes back down this prop — blank paths dropped,
111
+ * missing leading slashes added — but the card kept its own copy. The Save button stayed
112
+ * lit forever because `isDirty` was comparing the operator's draft against a value the
113
+ * database no longer held, and the preview kept rendering custom rules that had been
114
+ * tidied away server-side, i.e. showing a file nobody would ever be served.
115
+ *
116
+ * The reset is gated on the settings' CONTENT, not on the prop's identity. A server
117
+ * component builds a fresh object on every render, so `settings` is a new reference after
118
+ * every `router.refresh()` — including refreshes triggered by other cards on this screen.
119
+ * An effect keyed on the object itself would therefore wipe out half-typed rules whenever
120
+ * anything else revalidated. Comparing signatures means the state is only ever thrown
121
+ * away when the stored value really moved, which is exactly the moment the local copy is
122
+ * known to be obsolete.
123
+ */
124
+ const settingsSignature = useMemo(() => robotsSettingsSignature(settings), [settings]);
125
+ const seededSignatureRef = useRef(settingsSignature);
126
+
127
+ useEffect(() => {
128
+ if (seededSignatureRef.current === settingsSignature) {
129
+ return;
130
+ }
131
+
132
+ seededSignatureRef.current = settingsSignature;
133
+ setCustomRules(settings.customRules);
134
+ setIsIndexingEnabled(settings.isIndexingEnabled);
135
+ setSitemapEnabled(settings.sitemapEnabled);
136
+ // Fresh render keys are minted here, so the textareas remount rather than reusing
137
+ // nodes across a rule list that may have changed length — the same identity problem
138
+ // `DraftUserAgentRule.key` exists to solve.
139
+ setDraftRules(toDraftRules(settings.userAgentRules));
140
+ }, [settings, settingsSignature]);
141
+
142
+ /**
143
+ * The form's current value in the engine's shape.
144
+ *
145
+ * Derived rather than stored, so there is exactly one source of truth for what is
146
+ * about to be saved — and the preview below and the payload sent to the server are
147
+ * literally the same object.
148
+ */
149
+ const draftSettings: RobotsSettings = useMemo(
150
+ () => ({
151
+ customRules,
152
+ isIndexingEnabled,
153
+ sitemapEnabled,
154
+ userAgentRules: draftRules.map((rule) => ({
155
+ allow: parsePathList(rule.allowText),
156
+ disallow: parsePathList(rule.disallowText),
157
+ userAgent: rule.userAgent.trim(),
158
+ })),
159
+ }),
160
+ [customRules, draftRules, isIndexingEnabled, sitemapEnabled]
161
+ );
162
+
163
+ /**
164
+ * Whether anything has actually changed, which is what gates the Save button and
165
+ * Ctrl+S.
166
+ *
167
+ * Compared through `robotsSettingsSignature`, which reads the fields explicitly. A raw
168
+ * `JSON.stringify` of both sides worked only because the form's object literal and
169
+ * `normalizeRobotsSettings`' object literal happen to list their keys in the same
170
+ * (alphabetical, per the house convention) order; the day one of them was reordered the
171
+ * form would have been born dirty with nothing pointing at why. The failure mode is
172
+ * benign in one direction only — a false "dirty" costs a redundant write, a false
173
+ * "clean" loses an edit — so this comparison is allowed to be conservative but must
174
+ * never be clever enough to miss a real change.
175
+ */
176
+ const isDirty = useMemo(
177
+ () => robotsSettingsSignature(draftSettings) !== settingsSignature,
178
+ [draftSettings, settingsSignature]
179
+ );
180
+
181
+ // What crawlers would actually receive, produced by the public serialiser. In the
182
+ // sandbox that answer is fixed, so the second preview shows what a real install
183
+ // would serve from the same settings — otherwise a sandbox visitor edits the form,
184
+ // watches the preview refuse to change, and concludes the screen is broken.
185
+ const servedPreview = useMemo(
186
+ () => buildRobotsTxt(draftSettings, { isSandbox, sitemapUrl }),
187
+ [draftSettings, isSandbox, sitemapUrl]
188
+ );
189
+ const ownInstallPreview = useMemo(
190
+ () => (isSandbox ? buildRobotsTxt(draftSettings, { isSandbox: false, sitemapUrl }) : null),
191
+ [draftSettings, isSandbox, sitemapUrl]
192
+ );
193
+
194
+ // Lines from the "extra lines" box that the served file cannot carry — comments,
195
+ // and anything without a `directive: value` shape. The public file is produced by
196
+ // Next's metadata route, which emits only the directives it models, so a line that
197
+ // survives in this component's text preview would not necessarily survive to a
198
+ // crawler. Showing the difference is the whole point: a preview an operator trusts
199
+ // and that quietly drops a directive is worse than no preview at all, because they
200
+ // walk away believing a rule is live.
201
+ const unservedCustomLines = useMemo(
202
+ () => listUnservedCustomRuleLines(draftSettings, { isSandbox, sitemapUrl }),
203
+ [draftSettings, isSandbox, sitemapUrl]
204
+ );
205
+
206
+ const save = useCallback(() => {
207
+ startTransition(async () => {
208
+ const result = await saveRobotsSettings(draftSettings);
209
+
210
+ if (result.ok) {
211
+ toast.success(result.message);
212
+ // Pulls the canonical, normalised row back down — blank paths dropped, missing
213
+ // leading slashes added — so the form stops showing a draft the database has
214
+ // already tidied up.
215
+ router.refresh();
216
+ } else {
217
+ toast.error(result.error);
218
+ }
219
+ });
220
+ }, [draftSettings, router]);
221
+
222
+ // Ctrl/Cmd+S saves, which is the reflex of anyone who has just edited a text file —
223
+ // and this card is, to the operator, a text file. Hand-rolled rather than using a
224
+ // `useHotkeys` hook because this workspace has no hotkey library; a listener on the
225
+ // window is the whole of what such a hook would install anyway. The ref keeps the
226
+ // handler stable so the listener is bound once instead of on every keystroke.
227
+ const saveRef = useRef(save);
228
+ saveRef.current = save;
229
+ const canSaveRef = useRef(false);
230
+ canSaveRef.current = isDirty && !isPending;
231
+
232
+ useEffect(() => {
233
+ const onKeyDown = (event: KeyboardEvent) => {
234
+ if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') {
235
+ return;
236
+ }
237
+
238
+ // Prevented unconditionally: the browser's "save this page" dialog is never what
239
+ // somebody wants on a settings screen, and swallowing it while there is nothing
240
+ // to save is less surprising than opening it.
241
+ event.preventDefault();
242
+
243
+ if (canSaveRef.current) {
244
+ saveRef.current();
245
+ }
246
+ };
247
+
248
+ window.addEventListener('keydown', onKeyDown);
249
+ return () => window.removeEventListener('keydown', onKeyDown);
250
+ }, []);
251
+
252
+ const updateRule = (key: number, patch: Partial<DraftUserAgentRule>) => {
253
+ setDraftRules((current) =>
254
+ current.map((rule) => (rule.key === key ? { ...rule, ...patch } : rule))
255
+ );
256
+ };
257
+
258
+ return (
259
+ <Card>
260
+ <CardHeader>
261
+ <CardTitle>Search engines &amp; robots.txt</CardTitle>
262
+ <CardDescription>
263
+ <span className="font-mono">/robots.txt</span> is the first file a crawler asks for. It
264
+ says which parts of the site may be fetched — it is a request, honoured by the major
265
+ search engines and ignored by anything malicious, so it is a traffic-shaping tool and
266
+ never a security control. Never list a private path here expecting it to be hidden:
267
+ this file is public, and naming a URL in it is the most reliable way to advertise it.
268
+ </CardDescription>
269
+ </CardHeader>
270
+
271
+ <CardContent className="space-y-6">
272
+ {isSandbox && (
273
+ <div className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
274
+ <Info className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
275
+ <div className="space-y-1">
276
+ <p className="font-medium">This sandbox always serves the same robots.txt.</p>
277
+ <p className="text-muted-foreground">
278
+ The demo is a copy of a real site and must stay out of Google&apos;s index, which it
279
+ does by allowing crawling and answering every request with a{' '}
280
+ <span className="font-mono">noindex</span> header — a crawler can only obey a
281
+ noindex it is allowed to fetch. Blocking the crawl would leave the demo&apos;s URLs
282
+ stranded in the index instead. Edit freely: the second preview below shows what your
283
+ own install would serve.
284
+ </p>
285
+ </div>
286
+ </div>
287
+ )}
288
+
289
+ <div className="space-y-4">
290
+ <label className="flex items-start gap-3 text-sm" htmlFor="robots-indexing">
291
+ <Checkbox
292
+ checked={isIndexingEnabled}
293
+ className="mt-0.5"
294
+ id="robots-indexing"
295
+ onCheckedChange={(checked) => setIsIndexingEnabled(checked === true)}
296
+ />
297
+ <span>
298
+ <span className="font-medium">Allow search engines to index this site</span>
299
+ <span className="block text-muted-foreground">
300
+ Turning this off replaces every rule below with a site-wide{' '}
301
+ <span className="font-mono">Disallow: /</span>, asking every crawler to stay away
302
+ from the entire site. Your rules are kept and reappear the moment you switch it back
303
+ on. Use it for a site that is not ready to be public — and expect pages already in
304
+ the index to take weeks to drop out.
305
+ </span>
306
+ </span>
307
+ </label>
308
+
309
+ <label className="flex items-start gap-3 text-sm" htmlFor="robots-sitemap">
310
+ <Checkbox
311
+ checked={sitemapEnabled}
312
+ className="mt-0.5"
313
+ id="robots-sitemap"
314
+ onCheckedChange={(checked) => setSitemapEnabled(checked === true)}
315
+ />
316
+ <span>
317
+ <span className="font-medium">Include a sitemap reference</span>
318
+ <span className="block text-muted-foreground">
319
+ Adds <span className="font-mono">Sitemap: {sitemapUrl}</span>, which is how a crawler
320
+ discovers pages nothing links to. It is dropped automatically while indexing is off,
321
+ because inviting a crawler to a sitemap you have just told it not to fetch is a
322
+ contradiction.
323
+ </span>
324
+ </span>
325
+ </label>
326
+
327
+ {sitemapEnabled && !isSiteUrlConfigured && (
328
+ <div className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
329
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
330
+ <p>
331
+ No public site URL is configured, so the sitemap line would point at{' '}
332
+ <span className="font-mono">{sitemapUrl}</span> — an address no crawler can reach.
333
+ Set <span className="font-mono">NEXT_PUBLIC_URL</span> for this deployment.
334
+ </p>
335
+ </div>
336
+ )}
337
+ </div>
338
+
339
+ <Separator />
340
+
341
+ <div className="space-y-4">
342
+ <div>
343
+ <h3 className="text-sm font-semibold">Crawler rules</h3>
344
+ <p className="text-sm text-muted-foreground">
345
+ One block per crawler. <span className="font-mono">*</span> means every crawler that
346
+ has no block of its own. Put one path per line — a path is matched as a prefix, so{' '}
347
+ <span className="font-mono">/admin</span> also covers{' '}
348
+ <span className="font-mono">/admin/anything</span>.
349
+ </p>
350
+ </div>
351
+
352
+ {draftRules.length === 0 && (
353
+ <p className="text-sm text-muted-foreground">
354
+ No blocks. A robots.txt with no rules at all is meaningless rather than strict, so one
355
+ permissive <span className="font-mono">User-agent: *</span> block is served instead —
356
+ see the preview.
357
+ </p>
358
+ )}
359
+
360
+ {draftRules.map((rule, index) => (
361
+ <div className="space-y-4 rounded-lg border bg-muted/30 p-4" key={rule.key}>
362
+ <div className="flex items-end gap-3">
363
+ <div className="flex-1 space-y-2">
364
+ <Label htmlFor={`robots-agent-${rule.key}`}>Crawler (user agent)</Label>
365
+ <Input
366
+ className="font-mono"
367
+ id={`robots-agent-${rule.key}`}
368
+ onChange={(event) => updateRule(rule.key, { userAgent: event.target.value })}
369
+ placeholder="*"
370
+ spellCheck={false}
371
+ value={rule.userAgent}
372
+ />
373
+ </div>
374
+ <Button
375
+ aria-label={`Remove the ${rule.userAgent || 'unnamed'} block`}
376
+ onClick={() =>
377
+ setDraftRules((current) => current.filter((entry) => entry.key !== rule.key))
378
+ }
379
+ size="sm"
380
+ variant="outline"
381
+ >
382
+ <Trash2 className="h-4 w-4" />
383
+ </Button>
384
+ </div>
385
+
386
+ {rule.userAgent.trim() === '' && (
387
+ <p className="text-xs text-destructive">
388
+ A block with no crawler name is dropped when you save — a bare{' '}
389
+ <span className="font-mono">User-agent:</span> line would apply to nobody and
390
+ silently orphan the rules under it.
391
+ </p>
392
+ )}
393
+
394
+ <div className="grid gap-4 sm:grid-cols-2">
395
+ <div className="space-y-2">
396
+ <Label htmlFor={`robots-allow-${rule.key}`}>Allow</Label>
397
+ <textarea
398
+ className={textareaClass}
399
+ id={`robots-allow-${rule.key}`}
400
+ onChange={(event) => updateRule(rule.key, { allowText: event.target.value })}
401
+ placeholder={'/'}
402
+ spellCheck={false}
403
+ value={rule.allowText}
404
+ />
405
+ </div>
406
+ <div className="space-y-2">
407
+ <Label htmlFor={`robots-disallow-${rule.key}`}>Disallow</Label>
408
+ <textarea
409
+ className={textareaClass}
410
+ id={`robots-disallow-${rule.key}`}
411
+ onChange={(event) => updateRule(rule.key, { disallowText: event.target.value })}
412
+ placeholder={'/cms\n/api'}
413
+ spellCheck={false}
414
+ value={rule.disallowText}
415
+ />
416
+ </div>
417
+ </div>
418
+
419
+ {index === 0 && (
420
+ <p className="text-xs text-muted-foreground">
421
+ Paths are tidied on save: a missing leading slash is added and blank lines are
422
+ dropped. A pattern starting with <span className="font-mono">*</span> is left
423
+ alone, because <span className="font-mono">*.pdf$</span> would mean something else
424
+ with a slash in front of it.
425
+ </p>
426
+ )}
427
+ </div>
428
+ ))}
429
+
430
+ <Button
431
+ onClick={() =>
432
+ setDraftRules((current) => [
433
+ ...current,
434
+ { allowText: '', disallowText: '', key: (nextRuleKey += 1), userAgent: '' },
435
+ ])
436
+ }
437
+ size="sm"
438
+ variant="outline"
439
+ >
440
+ <Plus className="mr-2 h-4 w-4" />
441
+ Add a crawler block
442
+ </Button>
443
+ </div>
444
+
445
+ <Separator />
446
+
447
+ <div className="space-y-2">
448
+ <Label htmlFor="robots-custom">Extra lines</Label>
449
+ <textarea
450
+ className={`${textareaClass} min-h-[120px]`}
451
+ id="robots-custom"
452
+ onChange={(event) => setCustomRules(event.target.value)}
453
+ placeholder={'# Anything valid in robots.txt\nUser-agent: GPTBot\nDisallow: /'}
454
+ spellCheck={false}
455
+ value={customRules}
456
+ />
457
+ <p className="text-xs text-muted-foreground">
458
+ Appended to the file exactly as typed, after the blocks above and before the sitemap
459
+ line. Use it for directives this form does not model — a{' '}
460
+ <span className="font-mono">Crawl-delay</span>, a comment, an AI-crawler block. These
461
+ lines survive the indexing switch, so an exception you need to keep working stays here.
462
+ Nothing validates them; the preview is the check.
463
+ </p>
464
+ </div>
465
+
466
+ <Separator />
467
+
468
+ <div className="space-y-2">
469
+ <h3 className="text-sm font-semibold">
470
+ {isSandbox ? 'What this sandbox serves at /robots.txt' : 'What crawlers will receive'}
471
+ </h3>
472
+ <p className="text-sm text-muted-foreground">
473
+ Generated by the same code that serves the file, so this is the file — not a
474
+ description of it.
475
+ </p>
476
+ <pre className="overflow-x-auto rounded-md border bg-muted p-3 text-xs">
477
+ <code>{servedPreview}</code>
478
+ </pre>
479
+
480
+ {unservedCustomLines.length > 0 && (
481
+ <div className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
482
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
483
+ <div>
484
+ <p className="font-medium">
485
+ {unservedCustomLines.length === 1
486
+ ? 'One extra line will not appear in the served file:'
487
+ : `${unservedCustomLines.length} extra lines will not appear in the served file:`}
488
+ </p>
489
+ <ul className="mt-2 space-y-1">
490
+ {unservedCustomLines.map((line) => (
491
+ <li key={line} className="font-mono text-xs">
492
+ {line}
493
+ </li>
494
+ ))}
495
+ </ul>
496
+ <p className="mt-2 text-xs">
497
+ {isSandbox
498
+ ? 'Sandbox deployments serve a fixed robots.txt, so extra lines are inert here. They will apply on your own install.'
499
+ : 'robots.txt is served through the Next.js metadata route, which emits only recognised directives — so comments and lines without a "Directive: value" shape are dropped. No crawler acts on those lines, so nothing is lost, but they will not be in the file.'}
500
+ </p>
501
+ </div>
502
+ </div>
503
+ )}
504
+ </div>
505
+
506
+ {ownInstallPreview !== null && (
507
+ <div className="space-y-2">
508
+ <h3 className="text-sm font-semibold">What your own install would serve</h3>
509
+ <pre className="overflow-x-auto rounded-md border bg-muted p-3 text-xs">
510
+ <code>{ownInstallPreview}</code>
511
+ </pre>
512
+ </div>
513
+ )}
514
+
515
+ <div className="flex items-center gap-3">
516
+ <Button disabled={!isDirty || isPending} onClick={save}>
517
+ <Save className="mr-2 h-4 w-4" />
518
+ {isPending ? 'Saving…' : 'Save robots settings'}
519
+ </Button>
520
+ {isDirty && !isPending && (
521
+ <span className="text-xs text-muted-foreground">
522
+ Unsaved changes — Ctrl+S also saves.
523
+ </span>
524
+ )}
525
+ </div>
526
+ </CardContent>
527
+ </Card>
528
+ );
529
+ }