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,510 +1,510 @@
1
- /**
2
- * Renders the operator's stored robots settings into the two shapes the app needs:
3
- * the `MetadataRoute.Robots` object Next.js serialises for `app/robots.ts`, and the
4
- * literal robots.txt text the admin preview shows, because somebody has to be able to
5
- * read the file they are about to publish.
6
- *
7
- * Those two are not siblings, and that is the whole point of this module. The
8
- * metadata object is the single source of truth; the text is produced from it by
9
- * {@link renderRobotsMetadata}, a line-for-line port of the serialiser Next actually
10
- * runs — `resolveRobots` in
11
- * `next/dist/build/webpack/loaders/metadata/resolve-route-data`, which the route
12
- * module Next generates for a dynamic `robots.ts` calls as
13
- * `resolveRouteData(await handler(), 'robots')`. The preview is therefore the file,
14
- * byte for byte, and not a second opinion about it.
15
- *
16
- * It used to be a second opinion, and that was a bug worth recording. `buildRobotsTxt`
17
- * appended `settings.customRules` verbatim while `buildRobotsMetadata` could only
18
- * re-express the standard directives it managed to parse, so every other custom line
19
- * was shown to the operator as live and never reached a crawler. The wrong assumption
20
- * behind that split was that `MetadataRoute.Robots` has no escape hatch for arbitrary
21
- * lines. It has one: each rule's `other` record, which Next emits verbatim as
22
- * `key: value`. Custom lines now genuinely reach the served file, and the two kinds
23
- * that still cannot be represented — comments, and lines with no `:` at all, neither
24
- * of which any crawler acts on — are reported by {@link listUnservedCustomRuleLines}
25
- * so the SEO screen can say so, rather than being displayed as though they were live.
26
- *
27
- * Nothing here throws or reads the environment. Callers hand in `isSandbox` and the
28
- * sitemap URL, which keeps the module pure enough to test and — more usefully —
29
- * means the admin preview can render "what production would serve" rather than
30
- * "what this dev machine would serve".
31
- */
32
-
33
- import type { MetadataRoute } from 'next';
34
- import type { RobotsSettings } from '@nextblock-cms/utils/seo';
35
-
36
- export interface RobotsTxtOptions {
37
- /**
38
- * True on a disposable sandbox deployment. This overrides every stored setting,
39
- * so it is passed in rather than read from `process.env` here — the admin preview
40
- * must be able to show the production answer, and the sandbox's demo admin login
41
- * must not be able to change what the sandbox serves.
42
- */
43
- isSandbox: boolean;
44
- /**
45
- * Absolute URL of the sitemap, or null when there is nothing to advertise. Still
46
- * subject to `settings.sitemapEnabled`: a null (or blank) URL means "we could not
47
- * build one", the setting means "the operator does not want one".
48
- */
49
- sitemapUrl: string | null;
50
- }
51
-
52
- /**
53
- * One entry of `MetadataRoute.Robots['rules']`, derived from Next's own type rather
54
- * than restated, so a future change to the metadata contract surfaces here as a
55
- * compile error instead of as a silently ignored field.
56
- */
57
- type RobotsRuleList = Extract<MetadataRoute.Robots['rules'], readonly unknown[]>;
58
- type RobotsRule = RobotsRuleList[number];
59
-
60
- /**
61
- * The sandbox file, and the reasoning that has to travel with it.
62
- *
63
- * Sandbox is a copy of production, so it must stay out of the search index. The
64
- * counter-intuitive part is that we ALLOW crawling in order to achieve that. Every
65
- * sandbox response already carries `noindex` — the `X-Robots-Tag` header from
66
- * next.config.js plus the robots meta in app/layout.tsx — and Googlebot can only
67
- * obey a noindex it is allowed to fetch. `Disallow: /` would block the fetch, so
68
- * Google would never read the noindex and could leave URL-only entries in the index
69
- * forever: the exact opposite of what we want. The Sitemap line is omitted because
70
- * the sandbox sitemap is empty by design (see app/sitemap.ts).
71
- *
72
- * This branch ignores the stored settings wholesale, deliberately. A sandbox visitor
73
- * signs in with a shared demo admin account, and "the demo admin flipped indexing
74
- * off" must not be able to turn the sandbox into a permanently half-indexed ghost.
75
- */
76
- const SANDBOX_USER_AGENT_RULE = { allow: ['/'], disallow: [], userAgent: '*' } as const;
77
-
78
- /** The file served when an operator switches indexing off for the whole site. */
79
- const INDEXING_DISABLED_USER_AGENT_RULE = { allow: [], disallow: ['/'], userAgent: '*' } as const;
80
-
81
- /** What a site with no usable configuration gets: fully crawlable. */
82
- const PERMISSIVE_USER_AGENT_RULE = { allow: ['/'], disallow: [], userAgent: '*' } as const;
83
-
84
- interface ResolvedUserAgentRule {
85
- allow: string[];
86
- disallow: string[];
87
- userAgent: string;
88
- }
89
-
90
- /**
91
- * The single decision layer both serialisers run through.
92
- *
93
- * Returning the resolved groups rather than a formatted string is what makes the two
94
- * public functions incapable of disagreeing about who may crawl what: they differ
95
- * only in how they render this answer, never in what it is.
96
- */
97
- function resolveUserAgentRules(
98
- settings: RobotsSettings,
99
- options: RobotsTxtOptions,
100
- ): ResolvedUserAgentRule[] {
101
- if (options.isSandbox) {
102
- return [{ ...SANDBOX_USER_AGENT_RULE, allow: [...SANDBOX_USER_AGENT_RULE.allow], disallow: [] }];
103
- }
104
-
105
- if (!settings.isIndexingEnabled) {
106
- return [
107
- {
108
- ...INDEXING_DISABLED_USER_AGENT_RULE,
109
- allow: [],
110
- disallow: [...INDEXING_DISABLED_USER_AGENT_RULE.disallow],
111
- },
112
- ];
113
- }
114
-
115
- const rules = settings.userAgentRules.map((rule) => ({
116
- allow: [...rule.allow],
117
- disallow: [...rule.disallow],
118
- userAgent: rule.userAgent,
119
- }));
120
-
121
- // A robots.txt with no User-agent group is not a stricter file, it is a
122
- // meaningless one, and crawlers disagree about how to read one — so a
123
- // configuration that reduced to nothing falls back to the permissive wildcard
124
- // rather than to an empty file.
125
- return rules.length > 0
126
- ? rules
127
- : [{ ...PERMISSIVE_USER_AGENT_RULE, allow: [...PERMISSIVE_USER_AGENT_RULE.allow], disallow: [] }];
128
- }
129
-
130
- /**
131
- * The operator's free-form lines, normalised for parsing.
132
- *
133
- * CRLF is folded to LF because the textarea these come from submits whatever line
134
- * endings the operator's browser produced, and a Windows-authored block would
135
- * otherwise put stray carriage returns into a file that is parsed line by line.
136
- * Surrounding whitespace is trimmed so a block that is nothing but blank lines
137
- * reduces to the empty string.
138
- */
139
- function normalizeCustomRules(customRules: string): string {
140
- if (typeof customRules !== 'string') {
141
- return '';
142
- }
143
-
144
- return customRules.replace(/\r\n/g, '\n').trim();
145
- }
146
-
147
- /**
148
- * The sitemap line's URL, or null when there is nothing worth advertising.
149
- *
150
- * A whitespace-only URL is treated as absent rather than emitted, because
151
- * `Sitemap:` with a blank value is a line a crawler will try to fetch and fail on.
152
- */
153
- function resolveSitemapUrl(settings: RobotsSettings, options: RobotsTxtOptions): string | null {
154
- if (options.isSandbox || !settings.isIndexingEnabled || !settings.sitemapEnabled) {
155
- return null;
156
- }
157
-
158
- const trimmed = (options.sitemapUrl ?? '').trim();
159
- return trimmed === '' ? null : trimmed;
160
- }
161
-
162
- /** One custom-rules group while it is still being collected, before it becomes a rule. */
163
- interface CustomRuleGroup {
164
- allow: string[];
165
- crawlDelay?: number;
166
- disallow: string[];
167
- /** Directive name (original casing) to the values typed under it, in order. */
168
- other: Record<string, string[]>;
169
- userAgents: string[];
170
- }
171
-
172
- interface ParsedCustomRules {
173
- rules: RobotsRule[];
174
- /**
175
- * Lines the served file cannot carry, in the order they were typed. Every one of
176
- * them is a line crawlers would ignore anyway — see {@link parseCustomRules} — but
177
- * they are surfaced rather than swallowed so the operator is never left looking for
178
- * a line that quietly disappeared.
179
- */
180
- unserved: string[];
181
- }
182
-
183
- /**
184
- * Turns the free-form custom-rules block into `MetadataRoute.Robots` rules.
185
- *
186
- * Standard directives become the typed fields Next understands; everything else rides
187
- * the per-rule `other` record, which Next's serialiser emits verbatim as
188
- * `key: value`, preserving the key's casing. That covers the non-standard directives
189
- * operators actually write — `Clean-param`, `Request-rate`, `Host`, an extra
190
- * `Sitemap` — none of which had any representation before.
191
- *
192
- * Two shapes still cannot be represented, and both are reported as unserved rather
193
- * than pretended away:
194
- *
195
- * - comments, because every line Next emits is a `name: value` pair; and
196
- * - lines with no `:` (or an empty name), which are not robots.txt syntax at all.
197
- *
198
- * Neither changes what a crawler does, so the served file loses no meaning — but the
199
- * preview must not show them, and the operator deserves to be told why they are gone.
200
- *
201
- * Note that `other` lines render after their group's Allow/Disallow/Crawl-delay
202
- * regardless of where the operator typed them. That reordering is harmless (robots.txt
203
- * groups are unordered sets of directives) and, more to the point, it is visible: the
204
- * preview is generated from these same rules, so what the operator reads is what
205
- * crawlers get.
206
- */
207
- function parseCustomRules(customRules: string): ParsedCustomRules {
208
- const normalized = normalizeCustomRules(customRules);
209
- if (normalized === '') {
210
- return { rules: [], unserved: [] };
211
- }
212
-
213
- const groups: CustomRuleGroup[] = [];
214
- const unserved: string[] = [];
215
-
216
- for (const rawLine of normalized.split('\n')) {
217
- const line = rawLine.trim();
218
- if (line === '') {
219
- continue;
220
- }
221
-
222
- if (line.startsWith('#')) {
223
- unserved.push(line);
224
- continue;
225
- }
226
-
227
- const separator = line.indexOf(':');
228
- if (separator === -1) {
229
- unserved.push(line);
230
- continue;
231
- }
232
-
233
- const name = line.slice(0, separator).trim();
234
- const value = line.slice(separator + 1).trim();
235
- if (name === '') {
236
- unserved.push(line);
237
- continue;
238
- }
239
-
240
- const directive = name.toLowerCase();
241
- let current = groups[groups.length - 1];
242
-
243
- if (directive === 'user-agent') {
244
- // A blank agent name would open a group that applies to nobody, silently
245
- // orphaning every directive typed under it, so it is rejected outright.
246
- if (value === '') {
247
- unserved.push(line);
248
- continue;
249
- }
250
-
251
- // Consecutive `User-agent:` lines share the rules that follow them, which is
252
- // standard robots.txt and something operators genuinely write. Only start a
253
- // new group once the previous one has actually collected a directive.
254
- if (current && isEmptyGroup(current)) {
255
- current.userAgents.push(value);
256
- } else {
257
- groups.push({ allow: [], disallow: [], other: {}, userAgents: [value] });
258
- }
259
- continue;
260
- }
261
-
262
- // A directive with no preceding `User-agent:` is malformed robots.txt, but the
263
- // operator's intent is unambiguous, so it is attributed to the wildcard agent
264
- // rather than discarded.
265
- if (!current) {
266
- current = { allow: [], disallow: [], other: {}, userAgents: ['*'] };
267
- groups.push(current);
268
- }
269
-
270
- if (directive === 'allow') {
271
- // An empty value is kept rather than skipped: `Disallow:` with nothing after it
272
- // is the specified way of saying "nothing is disallowed", and dropping it would
273
- // turn a meaningful line into a missing one.
274
- current.allow.push(value);
275
- continue;
276
- }
277
-
278
- if (directive === 'disallow') {
279
- current.disallow.push(value);
280
- continue;
281
- }
282
-
283
- if (directive === 'crawl-delay') {
284
- const delay = Number.parseFloat(value);
285
- // Next emits the typed `crawlDelay` field only when it is truthy, so a zero,
286
- // a second crawl-delay in the same group, or something unparseable would be
287
- // dropped from the served file. Those go through `other` instead, where they
288
- // are emitted verbatim — the operator's line survives either way.
289
- if (current.crawlDelay === undefined && Number.isFinite(delay) && delay > 0) {
290
- current.crawlDelay = delay;
291
- } else {
292
- pushOther(current, name, value);
293
- }
294
- continue;
295
- }
296
-
297
- pushOther(current, name, value);
298
- }
299
-
300
- return { rules: groups.map(toCustomRule), unserved };
301
- }
302
-
303
- /** True while a group has collected nothing but `User-agent:` lines. */
304
- function isEmptyGroup(group: CustomRuleGroup): boolean {
305
- return (
306
- group.allow.length === 0 &&
307
- group.disallow.length === 0 &&
308
- group.crawlDelay === undefined &&
309
- Object.keys(group.other).length === 0
310
- );
311
- }
312
-
313
- /**
314
- * Records a non-standard directive. Repeats accumulate into an array because Next
315
- * emits one line per array entry, which is the only way a group can carry the same
316
- * directive name twice.
317
- */
318
- function pushOther(group: CustomRuleGroup, name: string, value: string): void {
319
- const existing = group.other[name];
320
- if (existing) {
321
- existing.push(value);
322
- } else {
323
- group.other[name] = [value];
324
- }
325
- }
326
-
327
- function toCustomRule(group: CustomRuleGroup): RobotsRule {
328
- const rule: RobotsRule = {
329
- userAgent: group.userAgents.length === 1 ? group.userAgents[0] : group.userAgents,
330
- };
331
-
332
- if (group.allow.length > 0) {
333
- rule.allow = group.allow;
334
- }
335
- if (group.disallow.length > 0) {
336
- rule.disallow = group.disallow;
337
- }
338
- if (group.crawlDelay !== undefined) {
339
- rule.crawlDelay = group.crawlDelay;
340
- }
341
-
342
- const otherKeys = Object.keys(group.other);
343
- if (otherKeys.length > 0) {
344
- const other: Record<string, string | string[]> = {};
345
- for (const key of otherKeys) {
346
- const values = group.other[key];
347
- other[key] = values.length === 1 ? values[0] : values;
348
- }
349
- rule.other = other;
350
- }
351
-
352
- return rule;
353
- }
354
-
355
- /**
356
- * The `MetadataRoute.Robots` object served at /robots.txt by `app/robots.ts`, and the
357
- * object {@link buildRobotsTxt} renders for the admin preview.
358
- *
359
- * Empty `allow` / `disallow` arrays are omitted rather than passed through: Next
360
- * renders each entry as its own line, so an empty array contributes nothing while
361
- * still occupying a field, and leaving it out keeps the emitted object honest about
362
- * what the operator actually configured.
363
- */
364
- export function buildRobotsMetadata(
365
- settings: RobotsSettings,
366
- options: RobotsTxtOptions,
367
- ): MetadataRoute.Robots {
368
- const rules: RobotsRule[] = resolveUserAgentRules(settings, options).map((rule) => {
369
- const metadataRule: RobotsRule = { userAgent: rule.userAgent };
370
- if (rule.allow.length > 0) {
371
- metadataRule.allow = rule.allow;
372
- }
373
- if (rule.disallow.length > 0) {
374
- metadataRule.disallow = rule.disallow;
375
- }
376
- // A group consisting of nothing but its `User-agent:` header is malformed, and
377
- // some parsers discard the groups that follow it. An empty `Disallow` value is
378
- // the specified way to say "nothing is disallowed", so that is what an otherwise
379
- // empty group carries.
380
- if (rule.allow.length === 0 && rule.disallow.length === 0) {
381
- metadataRule.disallow = [''];
382
- }
383
- return metadataRule;
384
- });
385
-
386
- // Custom rules survive an indexing-off switch, because they are how an operator
387
- // carves out the exceptions that must keep working regardless (a `.well-known`
388
- // path, a verification file). They do NOT survive the sandbox, for the reason
389
- // recorded on SANDBOX_USER_AGENT_RULE.
390
- if (!options.isSandbox) {
391
- rules.push(...parseCustomRules(settings.customRules).rules);
392
- }
393
-
394
- const result: MetadataRoute.Robots = { rules };
395
-
396
- const sitemapUrl = resolveSitemapUrl(settings, options);
397
- if (sitemapUrl) {
398
- result.sitemap = sitemapUrl;
399
- }
400
-
401
- return result;
402
- }
403
-
404
- /**
405
- * The literal robots.txt text for a settings object — what the admin preview shows.
406
- *
407
- * This is {@link buildRobotsMetadata} put through {@link renderRobotsMetadata}, which
408
- * is what makes the preview incapable of disagreeing with the served file: there is
409
- * one object, one renderer, and the renderer is Next's own algorithm. The route
410
- * itself still returns the metadata object rather than this string, so caching,
411
- * revalidation and content type are handled exactly the way they are for the sitemap.
412
- */
413
- export function buildRobotsTxt(settings: RobotsSettings, options: RobotsTxtOptions): string {
414
- return renderRobotsMetadata(buildRobotsMetadata(settings, options));
415
- }
416
-
417
- /**
418
- * The custom-rules lines that will not appear in the served file, so the SEO screen
419
- * can tell the operator instead of leaving them to notice the absence.
420
- *
421
- * In the sandbox that is the entire block: the sandbox answer ignores stored settings
422
- * wholesale (see SANDBOX_USER_AGENT_RULE), so every custom line is inert there.
423
- */
424
- export function listUnservedCustomRuleLines(
425
- settings: RobotsSettings,
426
- options: RobotsTxtOptions,
427
- ): string[] {
428
- const normalized = normalizeCustomRules(settings.customRules);
429
-
430
- if (options.isSandbox) {
431
- return normalized === ''
432
- ? []
433
- : normalized
434
- .split('\n')
435
- .map((line) => line.trim())
436
- .filter((line) => line !== '');
437
- }
438
-
439
- return parseCustomRules(normalized).unserved;
440
- }
441
-
442
- /**
443
- * Serialises a `MetadataRoute.Robots` object exactly as Next.js does.
444
- *
445
- * This is a deliberate port of `resolveRobots` in
446
- * `next/dist/build/webpack/loaders/metadata/resolve-route-data`, down to the
447
- * capitalised `User-Agent:`, the blank line after every group including the last, and
448
- * the order of fields within a group. It exists so the admin preview can be the
449
- * served bytes rather than an approximation of them; the colocated test asserts the
450
- * two agree by importing Next's serialiser directly, so a change to Next's rendering
451
- * fails a test here instead of silently turning the preview back into a lie.
452
- */
453
- export function renderRobotsMetadata(metadata: MetadataRoute.Robots): string {
454
- let content = '';
455
-
456
- const rules = Array.isArray(metadata.rules) ? metadata.rules : [metadata.rules];
457
-
458
- for (const rule of rules) {
459
- for (const agent of toArray(rule.userAgent || ['*'])) {
460
- content += `User-Agent: ${agent}\n`;
461
- }
462
-
463
- if (rule.allow) {
464
- for (const item of toArray(rule.allow)) {
465
- content += `Allow: ${item}\n`;
466
- }
467
- }
468
-
469
- if (rule.disallow) {
470
- for (const item of toArray(rule.disallow)) {
471
- content += `Disallow: ${item}\n`;
472
- }
473
- }
474
-
475
- if (rule.crawlDelay) {
476
- content += `Crawl-delay: ${rule.crawlDelay}\n`;
477
- }
478
-
479
- if (rule.other) {
480
- for (const key of Object.keys(rule.other)) {
481
- const value = rule.other[key];
482
- if (value === null || value === undefined) {
483
- continue;
484
- }
485
- for (const entry of toArray(value)) {
486
- content += `${key}: ${entry}\n`;
487
- }
488
- }
489
- }
490
-
491
- content += '\n';
492
- }
493
-
494
- if (metadata.host) {
495
- content += `Host: ${metadata.host}\n`;
496
- }
497
-
498
- if (metadata.sitemap) {
499
- for (const item of toArray(metadata.sitemap)) {
500
- content += `Sitemap: ${item}\n`;
501
- }
502
- }
503
-
504
- return content;
505
- }
506
-
507
- /** Next's `resolveArray`: a bare value becomes a one-element list, a list is left alone. */
508
- function toArray<T>(value: T | T[]): T[] {
509
- return Array.isArray(value) ? value : [value];
510
- }
1
+ /**
2
+ * Renders the operator's stored robots settings into the two shapes the app needs:
3
+ * the `MetadataRoute.Robots` object Next.js serialises for `app/robots.ts`, and the
4
+ * literal robots.txt text the admin preview shows, because somebody has to be able to
5
+ * read the file they are about to publish.
6
+ *
7
+ * Those two are not siblings, and that is the whole point of this module. The
8
+ * metadata object is the single source of truth; the text is produced from it by
9
+ * {@link renderRobotsMetadata}, a line-for-line port of the serialiser Next actually
10
+ * runs — `resolveRobots` in
11
+ * `next/dist/build/webpack/loaders/metadata/resolve-route-data`, which the route
12
+ * module Next generates for a dynamic `robots.ts` calls as
13
+ * `resolveRouteData(await handler(), 'robots')`. The preview is therefore the file,
14
+ * byte for byte, and not a second opinion about it.
15
+ *
16
+ * It used to be a second opinion, and that was a bug worth recording. `buildRobotsTxt`
17
+ * appended `settings.customRules` verbatim while `buildRobotsMetadata` could only
18
+ * re-express the standard directives it managed to parse, so every other custom line
19
+ * was shown to the operator as live and never reached a crawler. The wrong assumption
20
+ * behind that split was that `MetadataRoute.Robots` has no escape hatch for arbitrary
21
+ * lines. It has one: each rule's `other` record, which Next emits verbatim as
22
+ * `key: value`. Custom lines now genuinely reach the served file, and the two kinds
23
+ * that still cannot be represented — comments, and lines with no `:` at all, neither
24
+ * of which any crawler acts on — are reported by {@link listUnservedCustomRuleLines}
25
+ * so the SEO screen can say so, rather than being displayed as though they were live.
26
+ *
27
+ * Nothing here throws or reads the environment. Callers hand in `isSandbox` and the
28
+ * sitemap URL, which keeps the module pure enough to test and — more usefully —
29
+ * means the admin preview can render "what production would serve" rather than
30
+ * "what this dev machine would serve".
31
+ */
32
+
33
+ import type { MetadataRoute } from 'next';
34
+ import type { RobotsSettings } from '@nextblock-cms/utils/seo';
35
+
36
+ export interface RobotsTxtOptions {
37
+ /**
38
+ * True on a disposable sandbox deployment. This overrides every stored setting,
39
+ * so it is passed in rather than read from `process.env` here — the admin preview
40
+ * must be able to show the production answer, and the sandbox's demo admin login
41
+ * must not be able to change what the sandbox serves.
42
+ */
43
+ isSandbox: boolean;
44
+ /**
45
+ * Absolute URL of the sitemap, or null when there is nothing to advertise. Still
46
+ * subject to `settings.sitemapEnabled`: a null (or blank) URL means "we could not
47
+ * build one", the setting means "the operator does not want one".
48
+ */
49
+ sitemapUrl: string | null;
50
+ }
51
+
52
+ /**
53
+ * One entry of `MetadataRoute.Robots['rules']`, derived from Next's own type rather
54
+ * than restated, so a future change to the metadata contract surfaces here as a
55
+ * compile error instead of as a silently ignored field.
56
+ */
57
+ type RobotsRuleList = Extract<MetadataRoute.Robots['rules'], readonly unknown[]>;
58
+ type RobotsRule = RobotsRuleList[number];
59
+
60
+ /**
61
+ * The sandbox file, and the reasoning that has to travel with it.
62
+ *
63
+ * Sandbox is a copy of production, so it must stay out of the search index. The
64
+ * counter-intuitive part is that we ALLOW crawling in order to achieve that. Every
65
+ * sandbox response already carries `noindex` — the `X-Robots-Tag` header from
66
+ * next.config.js plus the robots meta in app/layout.tsx — and Googlebot can only
67
+ * obey a noindex it is allowed to fetch. `Disallow: /` would block the fetch, so
68
+ * Google would never read the noindex and could leave URL-only entries in the index
69
+ * forever: the exact opposite of what we want. The Sitemap line is omitted because
70
+ * the sandbox sitemap is empty by design (see app/sitemap.ts).
71
+ *
72
+ * This branch ignores the stored settings wholesale, deliberately. A sandbox visitor
73
+ * signs in with a shared demo admin account, and "the demo admin flipped indexing
74
+ * off" must not be able to turn the sandbox into a permanently half-indexed ghost.
75
+ */
76
+ const SANDBOX_USER_AGENT_RULE = { allow: ['/'], disallow: [], userAgent: '*' } as const;
77
+
78
+ /** The file served when an operator switches indexing off for the whole site. */
79
+ const INDEXING_DISABLED_USER_AGENT_RULE = { allow: [], disallow: ['/'], userAgent: '*' } as const;
80
+
81
+ /** What a site with no usable configuration gets: fully crawlable. */
82
+ const PERMISSIVE_USER_AGENT_RULE = { allow: ['/'], disallow: [], userAgent: '*' } as const;
83
+
84
+ interface ResolvedUserAgentRule {
85
+ allow: string[];
86
+ disallow: string[];
87
+ userAgent: string;
88
+ }
89
+
90
+ /**
91
+ * The single decision layer both serialisers run through.
92
+ *
93
+ * Returning the resolved groups rather than a formatted string is what makes the two
94
+ * public functions incapable of disagreeing about who may crawl what: they differ
95
+ * only in how they render this answer, never in what it is.
96
+ */
97
+ function resolveUserAgentRules(
98
+ settings: RobotsSettings,
99
+ options: RobotsTxtOptions,
100
+ ): ResolvedUserAgentRule[] {
101
+ if (options.isSandbox) {
102
+ return [{ ...SANDBOX_USER_AGENT_RULE, allow: [...SANDBOX_USER_AGENT_RULE.allow], disallow: [] }];
103
+ }
104
+
105
+ if (!settings.isIndexingEnabled) {
106
+ return [
107
+ {
108
+ ...INDEXING_DISABLED_USER_AGENT_RULE,
109
+ allow: [],
110
+ disallow: [...INDEXING_DISABLED_USER_AGENT_RULE.disallow],
111
+ },
112
+ ];
113
+ }
114
+
115
+ const rules = settings.userAgentRules.map((rule) => ({
116
+ allow: [...rule.allow],
117
+ disallow: [...rule.disallow],
118
+ userAgent: rule.userAgent,
119
+ }));
120
+
121
+ // A robots.txt with no User-agent group is not a stricter file, it is a
122
+ // meaningless one, and crawlers disagree about how to read one — so a
123
+ // configuration that reduced to nothing falls back to the permissive wildcard
124
+ // rather than to an empty file.
125
+ return rules.length > 0
126
+ ? rules
127
+ : [{ ...PERMISSIVE_USER_AGENT_RULE, allow: [...PERMISSIVE_USER_AGENT_RULE.allow], disallow: [] }];
128
+ }
129
+
130
+ /**
131
+ * The operator's free-form lines, normalised for parsing.
132
+ *
133
+ * CRLF is folded to LF because the textarea these come from submits whatever line
134
+ * endings the operator's browser produced, and a Windows-authored block would
135
+ * otherwise put stray carriage returns into a file that is parsed line by line.
136
+ * Surrounding whitespace is trimmed so a block that is nothing but blank lines
137
+ * reduces to the empty string.
138
+ */
139
+ function normalizeCustomRules(customRules: string): string {
140
+ if (typeof customRules !== 'string') {
141
+ return '';
142
+ }
143
+
144
+ return customRules.replace(/\r\n/g, '\n').trim();
145
+ }
146
+
147
+ /**
148
+ * The sitemap line's URL, or null when there is nothing worth advertising.
149
+ *
150
+ * A whitespace-only URL is treated as absent rather than emitted, because
151
+ * `Sitemap:` with a blank value is a line a crawler will try to fetch and fail on.
152
+ */
153
+ function resolveSitemapUrl(settings: RobotsSettings, options: RobotsTxtOptions): string | null {
154
+ if (options.isSandbox || !settings.isIndexingEnabled || !settings.sitemapEnabled) {
155
+ return null;
156
+ }
157
+
158
+ const trimmed = (options.sitemapUrl ?? '').trim();
159
+ return trimmed === '' ? null : trimmed;
160
+ }
161
+
162
+ /** One custom-rules group while it is still being collected, before it becomes a rule. */
163
+ interface CustomRuleGroup {
164
+ allow: string[];
165
+ crawlDelay?: number;
166
+ disallow: string[];
167
+ /** Directive name (original casing) to the values typed under it, in order. */
168
+ other: Record<string, string[]>;
169
+ userAgents: string[];
170
+ }
171
+
172
+ interface ParsedCustomRules {
173
+ rules: RobotsRule[];
174
+ /**
175
+ * Lines the served file cannot carry, in the order they were typed. Every one of
176
+ * them is a line crawlers would ignore anyway — see {@link parseCustomRules} — but
177
+ * they are surfaced rather than swallowed so the operator is never left looking for
178
+ * a line that quietly disappeared.
179
+ */
180
+ unserved: string[];
181
+ }
182
+
183
+ /**
184
+ * Turns the free-form custom-rules block into `MetadataRoute.Robots` rules.
185
+ *
186
+ * Standard directives become the typed fields Next understands; everything else rides
187
+ * the per-rule `other` record, which Next's serialiser emits verbatim as
188
+ * `key: value`, preserving the key's casing. That covers the non-standard directives
189
+ * operators actually write — `Clean-param`, `Request-rate`, `Host`, an extra
190
+ * `Sitemap` — none of which had any representation before.
191
+ *
192
+ * Two shapes still cannot be represented, and both are reported as unserved rather
193
+ * than pretended away:
194
+ *
195
+ * - comments, because every line Next emits is a `name: value` pair; and
196
+ * - lines with no `:` (or an empty name), which are not robots.txt syntax at all.
197
+ *
198
+ * Neither changes what a crawler does, so the served file loses no meaning — but the
199
+ * preview must not show them, and the operator deserves to be told why they are gone.
200
+ *
201
+ * Note that `other` lines render after their group's Allow/Disallow/Crawl-delay
202
+ * regardless of where the operator typed them. That reordering is harmless (robots.txt
203
+ * groups are unordered sets of directives) and, more to the point, it is visible: the
204
+ * preview is generated from these same rules, so what the operator reads is what
205
+ * crawlers get.
206
+ */
207
+ function parseCustomRules(customRules: string): ParsedCustomRules {
208
+ const normalized = normalizeCustomRules(customRules);
209
+ if (normalized === '') {
210
+ return { rules: [], unserved: [] };
211
+ }
212
+
213
+ const groups: CustomRuleGroup[] = [];
214
+ const unserved: string[] = [];
215
+
216
+ for (const rawLine of normalized.split('\n')) {
217
+ const line = rawLine.trim();
218
+ if (line === '') {
219
+ continue;
220
+ }
221
+
222
+ if (line.startsWith('#')) {
223
+ unserved.push(line);
224
+ continue;
225
+ }
226
+
227
+ const separator = line.indexOf(':');
228
+ if (separator === -1) {
229
+ unserved.push(line);
230
+ continue;
231
+ }
232
+
233
+ const name = line.slice(0, separator).trim();
234
+ const value = line.slice(separator + 1).trim();
235
+ if (name === '') {
236
+ unserved.push(line);
237
+ continue;
238
+ }
239
+
240
+ const directive = name.toLowerCase();
241
+ let current = groups[groups.length - 1];
242
+
243
+ if (directive === 'user-agent') {
244
+ // A blank agent name would open a group that applies to nobody, silently
245
+ // orphaning every directive typed under it, so it is rejected outright.
246
+ if (value === '') {
247
+ unserved.push(line);
248
+ continue;
249
+ }
250
+
251
+ // Consecutive `User-agent:` lines share the rules that follow them, which is
252
+ // standard robots.txt and something operators genuinely write. Only start a
253
+ // new group once the previous one has actually collected a directive.
254
+ if (current && isEmptyGroup(current)) {
255
+ current.userAgents.push(value);
256
+ } else {
257
+ groups.push({ allow: [], disallow: [], other: {}, userAgents: [value] });
258
+ }
259
+ continue;
260
+ }
261
+
262
+ // A directive with no preceding `User-agent:` is malformed robots.txt, but the
263
+ // operator's intent is unambiguous, so it is attributed to the wildcard agent
264
+ // rather than discarded.
265
+ if (!current) {
266
+ current = { allow: [], disallow: [], other: {}, userAgents: ['*'] };
267
+ groups.push(current);
268
+ }
269
+
270
+ if (directive === 'allow') {
271
+ // An empty value is kept rather than skipped: `Disallow:` with nothing after it
272
+ // is the specified way of saying "nothing is disallowed", and dropping it would
273
+ // turn a meaningful line into a missing one.
274
+ current.allow.push(value);
275
+ continue;
276
+ }
277
+
278
+ if (directive === 'disallow') {
279
+ current.disallow.push(value);
280
+ continue;
281
+ }
282
+
283
+ if (directive === 'crawl-delay') {
284
+ const delay = Number.parseFloat(value);
285
+ // Next emits the typed `crawlDelay` field only when it is truthy, so a zero,
286
+ // a second crawl-delay in the same group, or something unparseable would be
287
+ // dropped from the served file. Those go through `other` instead, where they
288
+ // are emitted verbatim — the operator's line survives either way.
289
+ if (current.crawlDelay === undefined && Number.isFinite(delay) && delay > 0) {
290
+ current.crawlDelay = delay;
291
+ } else {
292
+ pushOther(current, name, value);
293
+ }
294
+ continue;
295
+ }
296
+
297
+ pushOther(current, name, value);
298
+ }
299
+
300
+ return { rules: groups.map(toCustomRule), unserved };
301
+ }
302
+
303
+ /** True while a group has collected nothing but `User-agent:` lines. */
304
+ function isEmptyGroup(group: CustomRuleGroup): boolean {
305
+ return (
306
+ group.allow.length === 0 &&
307
+ group.disallow.length === 0 &&
308
+ group.crawlDelay === undefined &&
309
+ Object.keys(group.other).length === 0
310
+ );
311
+ }
312
+
313
+ /**
314
+ * Records a non-standard directive. Repeats accumulate into an array because Next
315
+ * emits one line per array entry, which is the only way a group can carry the same
316
+ * directive name twice.
317
+ */
318
+ function pushOther(group: CustomRuleGroup, name: string, value: string): void {
319
+ const existing = group.other[name];
320
+ if (existing) {
321
+ existing.push(value);
322
+ } else {
323
+ group.other[name] = [value];
324
+ }
325
+ }
326
+
327
+ function toCustomRule(group: CustomRuleGroup): RobotsRule {
328
+ const rule: RobotsRule = {
329
+ userAgent: group.userAgents.length === 1 ? group.userAgents[0] : group.userAgents,
330
+ };
331
+
332
+ if (group.allow.length > 0) {
333
+ rule.allow = group.allow;
334
+ }
335
+ if (group.disallow.length > 0) {
336
+ rule.disallow = group.disallow;
337
+ }
338
+ if (group.crawlDelay !== undefined) {
339
+ rule.crawlDelay = group.crawlDelay;
340
+ }
341
+
342
+ const otherKeys = Object.keys(group.other);
343
+ if (otherKeys.length > 0) {
344
+ const other: Record<string, string | string[]> = {};
345
+ for (const key of otherKeys) {
346
+ const values = group.other[key];
347
+ other[key] = values.length === 1 ? values[0] : values;
348
+ }
349
+ rule.other = other;
350
+ }
351
+
352
+ return rule;
353
+ }
354
+
355
+ /**
356
+ * The `MetadataRoute.Robots` object served at /robots.txt by `app/robots.ts`, and the
357
+ * object {@link buildRobotsTxt} renders for the admin preview.
358
+ *
359
+ * Empty `allow` / `disallow` arrays are omitted rather than passed through: Next
360
+ * renders each entry as its own line, so an empty array contributes nothing while
361
+ * still occupying a field, and leaving it out keeps the emitted object honest about
362
+ * what the operator actually configured.
363
+ */
364
+ export function buildRobotsMetadata(
365
+ settings: RobotsSettings,
366
+ options: RobotsTxtOptions,
367
+ ): MetadataRoute.Robots {
368
+ const rules: RobotsRule[] = resolveUserAgentRules(settings, options).map((rule) => {
369
+ const metadataRule: RobotsRule = { userAgent: rule.userAgent };
370
+ if (rule.allow.length > 0) {
371
+ metadataRule.allow = rule.allow;
372
+ }
373
+ if (rule.disallow.length > 0) {
374
+ metadataRule.disallow = rule.disallow;
375
+ }
376
+ // A group consisting of nothing but its `User-agent:` header is malformed, and
377
+ // some parsers discard the groups that follow it. An empty `Disallow` value is
378
+ // the specified way to say "nothing is disallowed", so that is what an otherwise
379
+ // empty group carries.
380
+ if (rule.allow.length === 0 && rule.disallow.length === 0) {
381
+ metadataRule.disallow = [''];
382
+ }
383
+ return metadataRule;
384
+ });
385
+
386
+ // Custom rules survive an indexing-off switch, because they are how an operator
387
+ // carves out the exceptions that must keep working regardless (a `.well-known`
388
+ // path, a verification file). They do NOT survive the sandbox, for the reason
389
+ // recorded on SANDBOX_USER_AGENT_RULE.
390
+ if (!options.isSandbox) {
391
+ rules.push(...parseCustomRules(settings.customRules).rules);
392
+ }
393
+
394
+ const result: MetadataRoute.Robots = { rules };
395
+
396
+ const sitemapUrl = resolveSitemapUrl(settings, options);
397
+ if (sitemapUrl) {
398
+ result.sitemap = sitemapUrl;
399
+ }
400
+
401
+ return result;
402
+ }
403
+
404
+ /**
405
+ * The literal robots.txt text for a settings object — what the admin preview shows.
406
+ *
407
+ * This is {@link buildRobotsMetadata} put through {@link renderRobotsMetadata}, which
408
+ * is what makes the preview incapable of disagreeing with the served file: there is
409
+ * one object, one renderer, and the renderer is Next's own algorithm. The route
410
+ * itself still returns the metadata object rather than this string, so caching,
411
+ * revalidation and content type are handled exactly the way they are for the sitemap.
412
+ */
413
+ export function buildRobotsTxt(settings: RobotsSettings, options: RobotsTxtOptions): string {
414
+ return renderRobotsMetadata(buildRobotsMetadata(settings, options));
415
+ }
416
+
417
+ /**
418
+ * The custom-rules lines that will not appear in the served file, so the SEO screen
419
+ * can tell the operator instead of leaving them to notice the absence.
420
+ *
421
+ * In the sandbox that is the entire block: the sandbox answer ignores stored settings
422
+ * wholesale (see SANDBOX_USER_AGENT_RULE), so every custom line is inert there.
423
+ */
424
+ export function listUnservedCustomRuleLines(
425
+ settings: RobotsSettings,
426
+ options: RobotsTxtOptions,
427
+ ): string[] {
428
+ const normalized = normalizeCustomRules(settings.customRules);
429
+
430
+ if (options.isSandbox) {
431
+ return normalized === ''
432
+ ? []
433
+ : normalized
434
+ .split('\n')
435
+ .map((line) => line.trim())
436
+ .filter((line) => line !== '');
437
+ }
438
+
439
+ return parseCustomRules(normalized).unserved;
440
+ }
441
+
442
+ /**
443
+ * Serialises a `MetadataRoute.Robots` object exactly as Next.js does.
444
+ *
445
+ * This is a deliberate port of `resolveRobots` in
446
+ * `next/dist/build/webpack/loaders/metadata/resolve-route-data`, down to the
447
+ * capitalised `User-Agent:`, the blank line after every group including the last, and
448
+ * the order of fields within a group. It exists so the admin preview can be the
449
+ * served bytes rather than an approximation of them; the colocated test asserts the
450
+ * two agree by importing Next's serialiser directly, so a change to Next's rendering
451
+ * fails a test here instead of silently turning the preview back into a lie.
452
+ */
453
+ export function renderRobotsMetadata(metadata: MetadataRoute.Robots): string {
454
+ let content = '';
455
+
456
+ const rules = Array.isArray(metadata.rules) ? metadata.rules : [metadata.rules];
457
+
458
+ for (const rule of rules) {
459
+ for (const agent of toArray(rule.userAgent || ['*'])) {
460
+ content += `User-Agent: ${agent}\n`;
461
+ }
462
+
463
+ if (rule.allow) {
464
+ for (const item of toArray(rule.allow)) {
465
+ content += `Allow: ${item}\n`;
466
+ }
467
+ }
468
+
469
+ if (rule.disallow) {
470
+ for (const item of toArray(rule.disallow)) {
471
+ content += `Disallow: ${item}\n`;
472
+ }
473
+ }
474
+
475
+ if (rule.crawlDelay) {
476
+ content += `Crawl-delay: ${rule.crawlDelay}\n`;
477
+ }
478
+
479
+ if (rule.other) {
480
+ for (const key of Object.keys(rule.other)) {
481
+ const value = rule.other[key];
482
+ if (value === null || value === undefined) {
483
+ continue;
484
+ }
485
+ for (const entry of toArray(value)) {
486
+ content += `${key}: ${entry}\n`;
487
+ }
488
+ }
489
+ }
490
+
491
+ content += '\n';
492
+ }
493
+
494
+ if (metadata.host) {
495
+ content += `Host: ${metadata.host}\n`;
496
+ }
497
+
498
+ if (metadata.sitemap) {
499
+ for (const item of toArray(metadata.sitemap)) {
500
+ content += `Sitemap: ${item}\n`;
501
+ }
502
+ }
503
+
504
+ return content;
505
+ }
506
+
507
+ /** Next's `resolveArray`: a bare value becomes a one-element list, a list is left alone. */
508
+ function toArray<T>(value: T | T[]): T[] {
509
+ return Array.isArray(value) ? value : [value];
510
+ }