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,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
+ }