@nextsparkjs/core 0.1.0-beta.189 → 0.1.0-beta.190

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 (456) hide show
  1. package/dist/components/api/keys/CreateApiKeyDialog.d.ts.map +1 -1
  2. package/dist/components/api/keys/CreateApiKeyDialog.js +2 -1
  3. package/dist/components/app/layouts/PublicFooter.d.ts.map +1 -1
  4. package/dist/components/app/layouts/PublicFooter.js +2 -23
  5. package/dist/components/app/misc/ThemeToggle.d.ts.map +1 -1
  6. package/dist/components/app/misc/ThemeToggle.js +2 -1
  7. package/dist/components/auth/forms/LoginForm.js +1 -1
  8. package/dist/components/auth/forms/SignupForm.d.ts.map +1 -1
  9. package/dist/components/auth/forms/SignupForm.js +2 -1
  10. package/dist/components/auth/pages/AuthErrorPage.d.ts.map +1 -1
  11. package/dist/components/auth/pages/AuthErrorPage.js +2 -1
  12. package/dist/components/billing/ManageBillingButton.d.ts.map +1 -1
  13. package/dist/components/billing/ManageBillingButton.js +2 -1
  14. package/dist/components/dashboard/block-editor/array-field.d.ts.map +1 -1
  15. package/dist/components/dashboard/block-editor/array-field.js +2 -1
  16. package/dist/components/dashboard/block-editor/block-picker.d.ts.map +1 -1
  17. package/dist/components/dashboard/block-editor/block-picker.js +2 -1
  18. package/dist/components/dashboard/block-editor/builder-editor-view.d.ts.map +1 -1
  19. package/dist/components/dashboard/block-editor/builder-editor-view.js +3 -2
  20. package/dist/components/dashboard/block-editor/config-panel.d.ts.map +1 -1
  21. package/dist/components/dashboard/block-editor/config-panel.js +3 -2
  22. package/dist/components/dashboard/block-editor/dynamic-form.d.ts.map +1 -1
  23. package/dist/components/dashboard/block-editor/dynamic-form.js +2 -1
  24. package/dist/components/dashboard/block-editor/entity-fields-sidebar.d.ts.map +1 -1
  25. package/dist/components/dashboard/block-editor/entity-fields-sidebar.js +3 -2
  26. package/dist/components/dashboard/block-editor/pattern-reference-preview.d.ts.map +1 -1
  27. package/dist/components/dashboard/block-editor/pattern-reference-preview.js +2 -1
  28. package/dist/components/dashboard/layouts/TopNavbar.d.ts.map +1 -1
  29. package/dist/components/dashboard/layouts/TopNavbar.js +4 -3
  30. package/dist/components/dashboard/misc/SearchDropdown.d.ts.map +1 -1
  31. package/dist/components/dashboard/misc/SearchDropdown.js +2 -1
  32. package/dist/components/dashboard/mobile/MobileTopBar.d.ts.map +1 -1
  33. package/dist/components/dashboard/mobile/MobileTopBar.js +2 -1
  34. package/dist/components/devtools/BlockDetailViewer.d.ts.map +1 -1
  35. package/dist/components/devtools/BlockDetailViewer.js +2 -1
  36. package/dist/components/devtools/BlocksViewer.d.ts.map +1 -1
  37. package/dist/components/devtools/BlocksViewer.js +2 -1
  38. package/dist/components/devtools/ConfigViewer.d.ts.map +1 -1
  39. package/dist/components/devtools/ConfigViewer.js +3 -2
  40. package/dist/components/devtools/TestCasesViewer.d.ts.map +1 -1
  41. package/dist/components/devtools/TestCasesViewer.js +3 -2
  42. package/dist/components/devtools/TestCoverageDashboard.d.ts.map +1 -1
  43. package/dist/components/devtools/TestCoverageDashboard.js +2 -1
  44. package/dist/components/devtools/api-explorer/ApiDocsModal.d.ts.map +1 -1
  45. package/dist/components/devtools/api-explorer/ApiDocsModal.js +0 -0
  46. package/dist/components/devtools/api-explorer/ApiExplorer.d.ts.map +1 -1
  47. package/dist/components/devtools/api-explorer/ApiExplorer.js +2 -1
  48. package/dist/components/devtools/scheduled-actions/actions-table.d.ts.map +1 -1
  49. package/dist/components/devtools/scheduled-actions/actions-table.js +3 -2
  50. package/dist/components/docs/docs-sidebar.d.ts.map +1 -1
  51. package/dist/components/docs/docs-sidebar.js +3 -2
  52. package/dist/components/entities/EntityFieldRenderer.d.ts.map +1 -1
  53. package/dist/components/entities/EntityFieldRenderer.js +2 -1
  54. package/dist/components/entities/EntityList.d.ts.map +1 -1
  55. package/dist/components/entities/EntityList.js +2 -1
  56. package/dist/components/entities/EntityTable.d.ts.map +1 -1
  57. package/dist/components/entities/EntityTable.js +3 -2
  58. package/dist/components/entities/ErrorBoundary.d.ts.map +1 -1
  59. package/dist/components/entities/ErrorBoundary.js +2 -1
  60. package/dist/components/entities/wrappers/EntityDetailWrapper.d.ts.map +1 -1
  61. package/dist/components/entities/wrappers/EntityDetailWrapper.js +3 -2
  62. package/dist/components/media/MediaCard.d.ts.map +1 -1
  63. package/dist/components/media/MediaCard.js +2 -1
  64. package/dist/components/media/MediaDetailPanel.d.ts.map +1 -1
  65. package/dist/components/media/MediaDetailPanel.js +2 -1
  66. package/dist/components/media/MediaList.d.ts.map +1 -1
  67. package/dist/components/media/MediaList.js +2 -1
  68. package/dist/components/media/MediaSelector.d.ts.map +1 -1
  69. package/dist/components/media/MediaSelector.js +2 -1
  70. package/dist/components/media/MediaUploadZone.d.ts.map +1 -1
  71. package/dist/components/media/MediaUploadZone.js +2 -1
  72. package/dist/components/public/entities/PublicEntityDetail.d.ts.map +1 -1
  73. package/dist/components/public/entities/PublicEntityDetail.js +2 -1
  74. package/dist/components/teams/TeamPendingInvitations.d.ts.map +1 -1
  75. package/dist/components/teams/TeamPendingInvitations.js +3 -2
  76. package/dist/components/ui/accordion.js +1 -1
  77. package/dist/components/ui/audio-upload.d.ts.map +1 -1
  78. package/dist/components/ui/audio-upload.js +2 -1
  79. package/dist/components/ui/avatar.d.ts +17 -1
  80. package/dist/components/ui/avatar.d.ts.map +1 -1
  81. package/dist/components/ui/avatar.js +29 -2
  82. package/dist/components/ui/badge.js +1 -1
  83. package/dist/components/ui/breadcrumb.d.ts +13 -0
  84. package/dist/components/ui/breadcrumb.d.ts.map +1 -1
  85. package/dist/components/ui/breadcrumb.js +23 -10
  86. package/dist/components/ui/button.js +1 -1
  87. package/dist/components/ui/card.js +1 -1
  88. package/dist/components/ui/checkbox.js +1 -1
  89. package/dist/components/ui/file-upload.d.ts.map +1 -1
  90. package/dist/components/ui/file-upload.js +2 -1
  91. package/dist/components/ui/image-upload.d.ts.map +1 -1
  92. package/dist/components/ui/image-upload.js +4 -3
  93. package/dist/components/ui/input.js +1 -1
  94. package/dist/components/ui/label.js +1 -1
  95. package/dist/components/ui/optimized-image.d.ts.map +1 -1
  96. package/dist/components/ui/optimized-image.js +5 -0
  97. package/dist/components/ui/pagination.d.ts +1 -1
  98. package/dist/components/ui/pagination.d.ts.map +1 -1
  99. package/dist/components/ui/pagination.js +3 -0
  100. package/dist/components/ui/progress.js +1 -1
  101. package/dist/components/ui/relation-display.d.ts.map +1 -1
  102. package/dist/components/ui/relation-display.js +3 -2
  103. package/dist/components/ui/separator.js +1 -1
  104. package/dist/components/ui/simple-relation-select.d.ts.map +1 -1
  105. package/dist/components/ui/simple-relation-select.js +7 -6
  106. package/dist/components/ui/skeleton.js +1 -1
  107. package/dist/components/ui/slider.js +1 -1
  108. package/dist/components/ui/switch.js +1 -1
  109. package/dist/components/ui/tabs.js +1 -1
  110. package/dist/components/ui/textarea.js +1 -1
  111. package/dist/components/ui/user-display.d.ts.map +1 -1
  112. package/dist/components/ui/user-display.js +2 -1
  113. package/dist/components/ui/user-select.d.ts.map +1 -1
  114. package/dist/components/ui/user-select.js +2 -1
  115. package/dist/components/ui/video-upload.d.ts.map +1 -1
  116. package/dist/components/ui/video-upload.js +3 -2
  117. package/dist/components/users/tables/UsersTable.d.ts.map +1 -1
  118. package/dist/components/users/tables/UsersTable.js +3 -2
  119. package/dist/contexts/SubscriptionContext.d.ts.map +1 -1
  120. package/dist/contexts/SubscriptionContext.js +4 -3
  121. package/dist/contexts/TeamContext.d.ts.map +1 -1
  122. package/dist/contexts/TeamContext.js +3 -2
  123. package/dist/hooks/useAccountPreferencesSync.d.ts.map +1 -1
  124. package/dist/hooks/useAccountPreferencesSync.js +2 -1
  125. package/dist/hooks/useApiExplorerNavigation.d.ts.map +1 -1
  126. package/dist/hooks/useApiExplorerNavigation.js +2 -1
  127. package/dist/hooks/useAuth.d.ts.map +1 -1
  128. package/dist/hooks/useAuth.js +3 -2
  129. package/dist/hooks/useEnsureUserMetadata.d.ts.map +1 -1
  130. package/dist/hooks/useEnsureUserMetadata.js +3 -2
  131. package/dist/hooks/useEntityWithMeta.d.ts.map +1 -1
  132. package/dist/hooks/useEntityWithMeta.js +3 -2
  133. package/dist/hooks/useInvoice.d.ts.map +1 -1
  134. package/dist/hooks/useInvoice.js +2 -1
  135. package/dist/hooks/useInvoices.d.ts.map +1 -1
  136. package/dist/hooks/useInvoices.js +2 -1
  137. package/dist/hooks/useMedia.d.ts.map +1 -1
  138. package/dist/hooks/useMedia.js +10 -9
  139. package/dist/hooks/useMediaUpload.d.ts.map +1 -1
  140. package/dist/hooks/useMediaUpload.js +2 -1
  141. package/dist/hooks/useMembership.d.ts.map +1 -1
  142. package/dist/hooks/useMembership.js +3 -2
  143. package/dist/hooks/usePatternUsages.d.ts.map +1 -1
  144. package/dist/hooks/usePatternUsages.js +3 -2
  145. package/dist/hooks/usePrefetch.d.ts.map +1 -1
  146. package/dist/hooks/usePrefetch.js +3 -2
  147. package/dist/hooks/useQuota.d.ts.map +1 -1
  148. package/dist/hooks/useQuota.js +2 -1
  149. package/dist/hooks/useTeamInvitations.d.ts.map +1 -1
  150. package/dist/hooks/useTeamInvitations.js +4 -3
  151. package/dist/hooks/useTeamMembers.d.ts.map +1 -1
  152. package/dist/hooks/useTeamMembers.js +5 -4
  153. package/dist/hooks/useTeamUpdate.d.ts.map +1 -1
  154. package/dist/hooks/useTeamUpdate.js +2 -1
  155. package/dist/hooks/useUserProfile.d.ts.map +1 -1
  156. package/dist/hooks/useUserProfile.js +3 -2
  157. package/dist/hooks/useUserSettings.d.ts.map +1 -1
  158. package/dist/hooks/useUserSettings.js +3 -2
  159. package/dist/lib/actions/entity.actions.d.ts.map +1 -1
  160. package/dist/lib/actions/entity.actions.js +2 -1
  161. package/dist/lib/api/entities.d.ts.map +1 -1
  162. package/dist/lib/api/entities.js +4 -3
  163. package/dist/lib/auth/verify-email-link.d.ts +10 -0
  164. package/dist/lib/auth/verify-email-link.d.ts.map +1 -0
  165. package/dist/lib/auth/verify-email-link.js +14 -0
  166. package/dist/lib/auth-client.d.ts +2 -0
  167. package/dist/lib/auth-client.d.ts.map +1 -1
  168. package/dist/lib/auth-client.js +4 -0
  169. package/dist/lib/auth.d.ts.map +1 -1
  170. package/dist/lib/auth.js +10 -2
  171. package/dist/lib/base-path.d.ts +85 -0
  172. package/dist/lib/base-path.d.ts.map +1 -0
  173. package/dist/lib/base-path.js +79 -0
  174. package/dist/lib/billing/schema.d.ts +1 -1
  175. package/dist/lib/billing/schema.d.ts.map +1 -1
  176. package/dist/lib/billing/schema.js +1 -1
  177. package/dist/lib/blocks/sanitize-html.d.ts.map +1 -1
  178. package/dist/lib/blocks/sanitize-html.js +18 -2
  179. package/dist/lib/cache/revalidate-tag.d.ts +14 -0
  180. package/dist/lib/cache/revalidate-tag.d.ts.map +1 -0
  181. package/dist/lib/cache/revalidate-tag.js +8 -0
  182. package/dist/lib/config/dashboard-schema.d.ts +1 -1
  183. package/dist/lib/config/dashboard-schema.d.ts.map +1 -1
  184. package/dist/lib/config/dashboard-schema.js +1 -1
  185. package/dist/lib/config/types.d.ts +36 -16
  186. package/dist/lib/config/types.d.ts.map +1 -1
  187. package/dist/lib/docs/access.d.ts +28 -0
  188. package/dist/lib/docs/access.d.ts.map +1 -0
  189. package/dist/lib/docs/access.js +16 -0
  190. package/dist/lib/docs/parser.d.ts.map +1 -1
  191. package/dist/lib/docs/parser.js +2 -1
  192. package/dist/lib/docs/remark-doc-links.d.ts +14 -0
  193. package/dist/lib/docs/remark-doc-links.d.ts.map +1 -0
  194. package/dist/lib/docs/remark-doc-links.js +38 -0
  195. package/dist/lib/entities/api-generator.d.ts +1 -1
  196. package/dist/lib/entities/api-generator.d.ts.map +1 -1
  197. package/dist/lib/entities/api-generator.js +1 -1
  198. package/dist/lib/entities/schema-generator.d.ts +1 -1
  199. package/dist/lib/entities/schema-generator.d.ts.map +1 -1
  200. package/dist/lib/entities/schema-generator.js +1 -1
  201. package/dist/lib/mcp/engine.js +1 -1
  202. package/dist/lib/mcp/schema-builder.d.ts +1 -1
  203. package/dist/lib/mcp/schema-builder.d.ts.map +1 -1
  204. package/dist/lib/mcp/schema-builder.js +1 -1
  205. package/dist/lib/mcp/tool-generator.js +1 -1
  206. package/dist/lib/mcp/types.d.ts +1 -1
  207. package/dist/lib/mcp/types.d.ts.map +1 -1
  208. package/dist/lib/media/schemas.d.ts +1 -1
  209. package/dist/lib/media/schemas.d.ts.map +1 -1
  210. package/dist/lib/media/schemas.js +1 -1
  211. package/dist/lib/middleware/index.d.ts +5 -1
  212. package/dist/lib/middleware/index.d.ts.map +1 -1
  213. package/dist/lib/middleware/index.js +3 -2
  214. package/dist/lib/services/meta.service.d.ts +60 -0
  215. package/dist/lib/services/meta.service.d.ts.map +1 -1
  216. package/dist/lib/services/meta.service.js +132 -12
  217. package/dist/lib/services/middleware.service.d.ts.map +1 -1
  218. package/dist/lib/services/middleware.service.js +3 -2
  219. package/dist/lib/teams/schema.d.ts +1 -1
  220. package/dist/lib/teams/schema.d.ts.map +1 -1
  221. package/dist/lib/teams/schema.js +1 -1
  222. package/dist/lib/teams/teams.api.d.ts.map +1 -1
  223. package/dist/lib/teams/teams.api.js +4 -3
  224. package/dist/lib/user-data-client.d.ts.map +1 -1
  225. package/dist/lib/user-data-client.js +3 -2
  226. package/dist/lib/validation/invoices.d.ts +1 -1
  227. package/dist/lib/validation/invoices.d.ts.map +1 -1
  228. package/dist/lib/validation/invoices.js +1 -1
  229. package/dist/lib/validation-schemas.d.ts +1 -1
  230. package/dist/lib/validation-schemas.d.ts.map +1 -1
  231. package/dist/lib/validation-schemas.js +1 -1
  232. package/dist/lib/validation.d.ts +1 -1
  233. package/dist/lib/validation.d.ts.map +1 -1
  234. package/dist/lib/validation.js +1 -1
  235. package/dist/messages/de/footer.json +0 -1
  236. package/dist/messages/de/index.d.ts +0 -1
  237. package/dist/messages/de/index.d.ts.map +1 -1
  238. package/dist/messages/en/footer.json +0 -1
  239. package/dist/messages/en/index.d.ts +0 -1
  240. package/dist/messages/en/index.d.ts.map +1 -1
  241. package/dist/messages/es/footer.json +0 -1
  242. package/dist/messages/es/index.d.ts +0 -1
  243. package/dist/messages/es/index.d.ts.map +1 -1
  244. package/dist/messages/fr/footer.json +0 -1
  245. package/dist/messages/fr/index.d.ts +0 -1
  246. package/dist/messages/fr/index.d.ts.map +1 -1
  247. package/dist/messages/it/footer.json +0 -1
  248. package/dist/messages/it/index.d.ts +0 -1
  249. package/dist/messages/it/index.d.ts.map +1 -1
  250. package/dist/messages/pt/footer.json +0 -1
  251. package/dist/messages/pt/index.d.ts +0 -1
  252. package/dist/messages/pt/index.d.ts.map +1 -1
  253. package/dist/templates/app/(auth)/accept-invite/[token]/page.tsx +4 -3
  254. package/dist/templates/app/(auth)/verify-email/page.tsx +4 -3
  255. package/dist/templates/app/(public)/docs/[section]/[page]/page.tsx +11 -0
  256. package/dist/templates/app/api/auth/[...all]/route.ts +15 -14
  257. package/dist/templates/app/api/internal/user-metadata/route.ts +6 -8
  258. package/dist/templates/app/api/user/plan-flags/route.ts +1 -1
  259. package/dist/templates/app/api/user/profile/route.ts +12 -17
  260. package/dist/templates/app/api/v1/api-keys/route.ts +1 -1
  261. package/dist/templates/app/api/v1/auth/signup-with-invite/route.ts +2 -1
  262. package/dist/templates/app/api/v1/billing/cancel/route.ts +1 -1
  263. package/dist/templates/app/api/v1/billing/change-plan/route.ts +1 -1
  264. package/dist/templates/app/api/v1/billing/check-action/route.ts +1 -1
  265. package/dist/templates/app/api/v1/billing/checkout/route.ts +1 -1
  266. package/dist/templates/app/api/v1/blocks/validate/route.ts +1 -1
  267. package/dist/templates/app/api/v1/media/[id]/tags/route.ts +1 -1
  268. package/dist/templates/app/api/v1/post-categories/[id]/route.ts +1 -1
  269. package/dist/templates/app/api/v1/post-categories/route.ts +1 -1
  270. package/dist/templates/app/api/v1/teams/[teamId]/members/route.ts +2 -1
  271. package/dist/templates/app/api/v1/teams/[teamId]/subscription/route.ts +3 -5
  272. package/dist/templates/app/api/v1/teams/docs.md +4 -9
  273. package/dist/templates/app/api/v1/teams/switch/route.ts +1 -1
  274. package/dist/templates/app/api/v1/users/[id]/meta/[key]/route.ts +1 -1
  275. package/dist/templates/app/api/v1/users/[id]/route.ts +1 -1
  276. package/dist/templates/app/api/v1/users/route.ts +1 -1
  277. package/dist/templates/app/dashboard/(main)/media/page.tsx +3 -1
  278. package/dist/templates/app/dashboard/settings/api-keys/page.tsx +5 -4
  279. package/dist/templates/app/dashboard/settings/profile/page.tsx +3 -2
  280. package/dist/templates/app/devtools/scheduled-actions/page.tsx +2 -1
  281. package/dist/templates/app/superadmin/docs/[section]/[page]/page.tsx +16 -0
  282. package/dist/templates/app/superadmin/subscriptions/page.tsx +3 -2
  283. package/dist/templates/app/superadmin/teams/[teamId]/page.tsx +5 -4
  284. package/dist/templates/app/superadmin/users/[userId]/page.tsx +2 -1
  285. package/dist/templates/app/superadmin/users/page.tsx +2 -1
  286. package/dist/templates/blocks/cta-section/component.tsx +3 -2
  287. package/dist/templates/blocks/cta-section/schema.ts +1 -1
  288. package/dist/templates/blocks/features-grid/component.tsx +2 -1
  289. package/dist/templates/blocks/features-grid/schema.ts +1 -1
  290. package/dist/templates/blocks/hero/component.tsx +3 -2
  291. package/dist/templates/blocks/hero/schema.ts +1 -1
  292. package/dist/templates/blocks/testimonials/component.tsx +2 -1
  293. package/dist/templates/blocks/testimonials/schema.ts +1 -1
  294. package/dist/templates/blocks/text-content/component.tsx +2 -1
  295. package/dist/templates/blocks/text-content/schema.ts +1 -1
  296. package/dist/templates/ci-workflows/README.md +1 -1
  297. package/dist/templates/ci-workflows/github/cypress-regression.yml +1 -1
  298. package/dist/templates/ci-workflows/github/cypress-smoke.yml +1 -1
  299. package/dist/templates/contents/plugins/starter/plugin/api/example/route.ts.template +1 -1
  300. package/dist/templates/contents/themes/starter/blocks/cta-section/component.tsx +3 -2
  301. package/dist/templates/contents/themes/starter/blocks/cta-section/schema.ts +1 -1
  302. package/dist/templates/contents/themes/starter/blocks/features-grid/component.tsx +2 -1
  303. package/dist/templates/contents/themes/starter/blocks/features-grid/schema.ts +1 -1
  304. package/dist/templates/contents/themes/starter/blocks/hero/component.tsx +3 -2
  305. package/dist/templates/contents/themes/starter/blocks/hero/schema.ts +1 -1
  306. package/dist/templates/contents/themes/starter/blocks/testimonials/component.tsx +3 -2
  307. package/dist/templates/contents/themes/starter/blocks/testimonials/schema.ts +1 -1
  308. package/dist/templates/contents/themes/starter/blocks/text-content/component.tsx +2 -1
  309. package/dist/templates/contents/themes/starter/blocks/text-content/schema.ts +1 -1
  310. package/dist/templates/contents/themes/starter/entities/posts/components/post-header.tsx +2 -1
  311. package/dist/templates/contents/themes/starter/lib/hooks/useAnalyticsData.ts +2 -1
  312. package/dist/templates/contents/themes/starter/lib/hooks/useTaskStats.ts +2 -1
  313. package/dist/templates/eslint.config.mjs +133 -9
  314. package/dist/templates/features/blog/blocks/post-content/component.tsx +2 -1
  315. package/dist/templates/features/blog/blocks/post-content/schema.ts +1 -1
  316. package/dist/templates/features/blog/entities/posts/components/post-header.tsx +2 -1
  317. package/dist/templates/features/pages/blocks/hero/component.tsx +3 -2
  318. package/dist/templates/features/pages/blocks/hero/schema.ts +1 -1
  319. package/dist/templates/next.config.mjs +50 -29
  320. package/dist/templates/proxy.ts +100 -21
  321. package/dist/templates/public/sw.js +2 -2
  322. package/dist/templates/scripts/cy-run-prod.cjs +5 -5
  323. package/dist/types/blocks.d.ts +1 -1
  324. package/dist/types/blocks.d.ts.map +1 -1
  325. package/dist/types/blocks.js +1 -1
  326. package/package.json +9 -5
  327. package/scripts/build/docs-registry.mjs +7 -7
  328. package/scripts/build/registry/__tests__/billing-registry.test.mjs +43 -0
  329. package/scripts/build/registry/__tests__/build-output.test.mjs +209 -0
  330. package/scripts/build/registry/__tests__/copy-race-child.mjs +14 -0
  331. package/scripts/build/registry/__tests__/direct-fs-writes.mjs +203 -0
  332. package/scripts/build/registry/__tests__/globals-css-tag.test.mjs +66 -0
  333. package/scripts/build/registry/__tests__/icons-discovery.test.mjs +530 -29
  334. package/scripts/build/registry/__tests__/page-generator.test.mjs +211 -37
  335. package/scripts/build/registry/__tests__/published-signatures.test.mjs +239 -0
  336. package/scripts/build/registry/__tests__/route-file-rule.test.mjs +170 -0
  337. package/scripts/build/registry/__tests__/safe-fs.test.mjs +333 -0
  338. package/scripts/build/registry/__tests__/template-analysis.test.mjs +142 -0
  339. package/scripts/build/registry/__tests__/template-registry.test.mjs +279 -9
  340. package/scripts/build/registry/__tests__/templates-plan.test.mjs +128 -0
  341. package/scripts/build/registry/__tests__/templates-tree.test.mjs +367 -0
  342. package/scripts/build/registry/__tests__/write-places.test.mjs +425 -0
  343. package/scripts/build/registry/config.mjs +6 -65
  344. package/scripts/build/registry/discovery/icons.mjs +498 -43
  345. package/scripts/build/registry/discovery/templates.mjs +1 -1
  346. package/scripts/build/registry/generators/billing-registry.mjs +1 -1
  347. package/scripts/build/registry/generators/docs-registry.mjs +3 -4
  348. package/scripts/build/registry/generators/feature-registry.mjs +1 -1
  349. package/scripts/build/registry/generators/permissions-registry.mjs +2 -2
  350. package/scripts/build/registry/generators/template-registry.mjs +65 -49
  351. package/scripts/build/registry/index.mjs +1 -2
  352. package/scripts/build/registry/post-build/index.mjs +1 -1
  353. package/scripts/build/registry/post-build/own-gitignores.mjs +220 -0
  354. package/scripts/build/registry/post-build/page-generator.mjs +512 -268
  355. package/scripts/build/registry/post-build/route-cleanup.mjs +16 -13
  356. package/scripts/build/registry/post-build/test-fixtures.mjs +26 -8
  357. package/scripts/build/registry/project-mode.mjs +76 -0
  358. package/scripts/build/registry/shared/typescript-compiler.mjs +77 -0
  359. package/scripts/build/registry/validate-env.test.mjs +12 -8
  360. package/scripts/build/registry/watch.mjs +3 -53
  361. package/scripts/build/registry/write-places.mjs +228 -0
  362. package/scripts/build/registry.mjs +53 -30
  363. package/scripts/build/safe-fs.mjs +279 -0
  364. package/scripts/build/templates-plan.mjs +44 -0
  365. package/scripts/build/theme.mjs +46 -42
  366. package/scripts/build/update-tsconfig.mjs +16 -15
  367. package/scripts/db/cluster-changes.mjs +205 -0
  368. package/scripts/db/connection-time-limits.mjs +53 -0
  369. package/scripts/db/inspect-server.mjs +108 -0
  370. package/scripts/db/migration-time-limit.mjs +695 -0
  371. package/scripts/db/run-migrations.mjs +46 -41
  372. package/scripts/db/ssl-config.mjs +194 -0
  373. package/scripts/db/theme-location.mjs +69 -0
  374. package/scripts/db/verify-tables.mjs +3 -8
  375. package/scripts/db/verify-teams.mjs +2 -6
  376. package/scripts/db/verify-theme-migrations.mjs +169 -0
  377. package/scripts/maintenance/core-updater.mjs +1003 -0
  378. package/scripts/maintenance/step-guard.mjs +74 -0
  379. package/scripts/maintenance/update-core.mjs +14 -831
  380. package/scripts/utils/console-guard.mjs +13 -0
  381. package/scripts/utils/generated-tag.mjs +66 -0
  382. package/scripts/utils/index.mjs +9 -1
  383. package/scripts/utils/logging.mjs +140 -0
  384. package/templates/app/(auth)/accept-invite/[token]/page.tsx +4 -3
  385. package/templates/app/(auth)/verify-email/page.tsx +4 -3
  386. package/templates/app/(public)/docs/[section]/[page]/page.tsx +11 -0
  387. package/templates/app/api/auth/[...all]/route.ts +15 -14
  388. package/templates/app/api/internal/user-metadata/route.ts +6 -8
  389. package/templates/app/api/user/plan-flags/route.ts +1 -1
  390. package/templates/app/api/user/profile/route.ts +12 -17
  391. package/templates/app/api/v1/api-keys/route.ts +1 -1
  392. package/templates/app/api/v1/auth/signup-with-invite/route.ts +2 -1
  393. package/templates/app/api/v1/billing/cancel/route.ts +1 -1
  394. package/templates/app/api/v1/billing/change-plan/route.ts +1 -1
  395. package/templates/app/api/v1/billing/check-action/route.ts +1 -1
  396. package/templates/app/api/v1/billing/checkout/route.ts +1 -1
  397. package/templates/app/api/v1/blocks/validate/route.ts +1 -1
  398. package/templates/app/api/v1/media/[id]/tags/route.ts +1 -1
  399. package/templates/app/api/v1/post-categories/[id]/route.ts +1 -1
  400. package/templates/app/api/v1/post-categories/route.ts +1 -1
  401. package/templates/app/api/v1/teams/[teamId]/members/route.ts +2 -1
  402. package/templates/app/api/v1/teams/[teamId]/subscription/route.ts +3 -5
  403. package/templates/app/api/v1/teams/docs.md +4 -9
  404. package/templates/app/api/v1/teams/switch/route.ts +1 -1
  405. package/templates/app/api/v1/users/[id]/meta/[key]/route.ts +1 -1
  406. package/templates/app/api/v1/users/[id]/route.ts +1 -1
  407. package/templates/app/api/v1/users/route.ts +1 -1
  408. package/templates/app/dashboard/(main)/media/page.tsx +3 -1
  409. package/templates/app/dashboard/settings/api-keys/page.tsx +5 -4
  410. package/templates/app/dashboard/settings/profile/page.tsx +3 -2
  411. package/templates/app/devtools/scheduled-actions/page.tsx +2 -1
  412. package/templates/app/superadmin/docs/[section]/[page]/page.tsx +16 -0
  413. package/templates/app/superadmin/subscriptions/page.tsx +3 -2
  414. package/templates/app/superadmin/teams/[teamId]/page.tsx +5 -4
  415. package/templates/app/superadmin/users/[userId]/page.tsx +2 -1
  416. package/templates/app/superadmin/users/page.tsx +2 -1
  417. package/templates/blocks/cta-section/component.tsx +3 -2
  418. package/templates/blocks/cta-section/schema.ts +1 -1
  419. package/templates/blocks/features-grid/component.tsx +2 -1
  420. package/templates/blocks/features-grid/schema.ts +1 -1
  421. package/templates/blocks/hero/component.tsx +3 -2
  422. package/templates/blocks/hero/schema.ts +1 -1
  423. package/templates/blocks/testimonials/component.tsx +2 -1
  424. package/templates/blocks/testimonials/schema.ts +1 -1
  425. package/templates/blocks/text-content/component.tsx +2 -1
  426. package/templates/blocks/text-content/schema.ts +1 -1
  427. package/templates/ci-workflows/README.md +1 -1
  428. package/templates/ci-workflows/github/cypress-regression.yml +1 -1
  429. package/templates/ci-workflows/github/cypress-smoke.yml +1 -1
  430. package/templates/contents/plugins/starter/plugin/api/example/route.ts.template +1 -1
  431. package/templates/contents/themes/starter/blocks/cta-section/component.tsx +3 -2
  432. package/templates/contents/themes/starter/blocks/cta-section/schema.ts +1 -1
  433. package/templates/contents/themes/starter/blocks/features-grid/component.tsx +2 -1
  434. package/templates/contents/themes/starter/blocks/features-grid/schema.ts +1 -1
  435. package/templates/contents/themes/starter/blocks/hero/component.tsx +3 -2
  436. package/templates/contents/themes/starter/blocks/hero/schema.ts +1 -1
  437. package/templates/contents/themes/starter/blocks/testimonials/component.tsx +3 -2
  438. package/templates/contents/themes/starter/blocks/testimonials/schema.ts +1 -1
  439. package/templates/contents/themes/starter/blocks/text-content/component.tsx +2 -1
  440. package/templates/contents/themes/starter/blocks/text-content/schema.ts +1 -1
  441. package/templates/contents/themes/starter/entities/posts/components/post-header.tsx +2 -1
  442. package/templates/contents/themes/starter/lib/hooks/useAnalyticsData.ts +2 -1
  443. package/templates/contents/themes/starter/lib/hooks/useTaskStats.ts +2 -1
  444. package/templates/eslint.config.mjs +133 -9
  445. package/templates/features/blog/blocks/post-content/component.tsx +2 -1
  446. package/templates/features/blog/blocks/post-content/schema.ts +1 -1
  447. package/templates/features/blog/entities/posts/components/post-header.tsx +2 -1
  448. package/templates/features/pages/blocks/hero/component.tsx +3 -2
  449. package/templates/features/pages/blocks/hero/schema.ts +1 -1
  450. package/templates/next.config.mjs +50 -29
  451. package/templates/proxy.ts +100 -21
  452. package/templates/public/sw.js +2 -2
  453. package/templates/scripts/cy-run-prod.cjs +5 -5
  454. package/tests/jest/__mocks__/@nextsparkjs/registries/template-registry.client.ts +11 -0
  455. package/tests/jest/__mocks__/next-server.js +10 -0
  456. package/scripts/build/docs.mjs +0 -392
@@ -7,13 +7,18 @@
7
7
  */
8
8
 
9
9
  import { createRequire } from 'node:module'
10
- import { existsSync } from 'fs'
11
- import { readFile, writeFile, mkdir } from 'fs/promises'
12
- import { join, dirname } from 'path'
10
+ import { constants, existsSync } from 'fs'
11
+ import { lstat, readdir, readFile } from 'fs/promises'
12
+ import { join, dirname, relative, sep } from 'path'
13
13
  import { fileURLToPath } from 'url'
14
14
 
15
- import { log, verbose } from '../../../utils/index.mjs'
16
- import { cleanupOrphanedTemplates } from './route-cleanup.mjs'
15
+ import { errorWithLines, log, verbose } from '../../../utils/index.mjs'
16
+ import { getProtectionLevel, ProtectionLevel } from '../../../../dist/config/protected-paths.js'
17
+ import { selectTypeScriptModule, loadTypeScriptFor } from '../shared/typescript-compiler.mjs'
18
+ import { ensureBackupsGitignore } from './own-gitignores.mjs'
19
+ import { projectFiles } from '../../safe-fs.mjs'
20
+
21
+ export { selectTypeScriptModule }
17
22
 
18
23
  const __filename = fileURLToPath(import.meta.url)
19
24
  const __dirname = dirname(__filename)
@@ -79,46 +84,27 @@ const SEGMENT_CONFIG_VALIDATORS = {
79
84
  maxDuration: value => typeof value === 'number' && Number.isInteger(value) && value >= 0,
80
85
  }
81
86
 
82
- // Loaded lazily (and only once) since not every consumer of this module needs
83
- // to parse a template - npm-mode builds without a template needing this path
84
- // shouldn't pay for loading a TypeScript compiler up front.
85
- let typescriptModulePromise = null
86
-
87
- /**
88
- * The TypeScript compiler, resolved from core first and then from the project:
89
- * it is a devDependency of core, so an installed core finds it through the
90
- * project that builds it, which has it to compile its own `.tsx` files.
91
- */
92
- function loadTypeScript() {
93
- if (!typescriptModulePromise) {
94
- typescriptModulePromise = import('typescript')
95
- .catch(() => createRequire(join(rootDir, 'package.json'))('typescript'))
96
- .then(
97
- module => module.default ?? module,
98
- error => {
99
- throw new Error(
100
- 'Parsing route-level exports out of a theme template requires the "typescript" package, but it could not be loaded ' +
101
- `from @nextsparkjs/core or from the project at ${rootDir}. Add "typescript" to the project's devDependencies. (${error.message})`
102
- )
103
- }
104
- )
105
- }
106
- return typescriptModulePromise
107
- }
108
-
109
87
  let nextParseModulePromise = null
110
88
 
111
89
  /**
112
- * Next.js's own module parser (SWC), resolved from the project and then from
113
- * core, or null when neither has Next.js.
90
+ * Next.js's own module parser and SWC binding loader, resolved from the project
91
+ * and then from core, or null when neither has Next.js.
114
92
  */
115
93
  function loadNextParseModule() {
116
94
  if (!nextParseModulePromise) {
117
- const path = 'next/dist/build/analysis/parse-module'
95
+ const parseModulePath = 'next/dist/build/analysis/parse-module'
96
+ const swcPath = 'next/dist/build/swc'
97
+ const loadFrom = require => ({
98
+ parseModule: require(parseModulePath).parseModule,
99
+ loadBindings: require(swcPath).loadBindings,
100
+ })
118
101
  nextParseModulePromise = Promise.resolve()
119
- .then(() => createRequire(join(rootDir, 'package.json'))(path))
120
- .catch(() => createRequire(import.meta.url)(path))
121
- .then(module => module.parseModule, () => null)
102
+ .then(() => loadFrom(createRequire(join(rootDir, 'package.json'))))
103
+ .catch(() => loadFrom(createRequire(import.meta.url)))
104
+ .then(async ({ parseModule, loadBindings }) => {
105
+ await loadBindings()
106
+ return parseModule
107
+ }, () => null)
122
108
  }
123
109
  return nextParseModulePromise
124
110
  }
@@ -201,32 +187,19 @@ function declaresDefaultExport(sourceFile, ts) {
201
187
  }
202
188
 
203
189
  /**
204
- * Parse a theme template's source and split its route-level exports into:
205
- * - `segmentConfig`: segment config keys that resolve to a literal value the
206
- * generated route file can re-declare (`{ name: value }`).
207
- * - `moduleExports`: exported names (functions, `metadata`, etc.) the
208
- * generated route file can safely forward with `export { ... } from`.
209
- * - `hasDefaultExport`: whether it exports a component as default, read from
210
- * the syntax tree so a comment or string mentioning `export default` doesn't
211
- * count and `export { Layout as default }` does.
190
+ * Parse a theme template's source into a TypeScript AST.
212
191
  *
213
- * A segment config key that IS exported but not as a literal `export const`
214
- * (an expression, an identifier, `let`/`var`, a re-export, or a value the
215
- * Next.js schema rejects) throws instead of silently dropping it - Next.js
216
- * would otherwise ignore the value and fall back to its default, and nothing
217
- * in the build would say so beyond a console warning.
192
+ * The parser recovers from a syntax error by skipping code, exports included. A
193
+ * template Next.js cannot parse either is reported rather than read in part. One
194
+ * it can is read from what TypeScript recovered, with a warning: Next.js compiles
195
+ * templates with SWC, which may know syntax the installed TypeScript does not.
218
196
  */
219
- export async function extractRouteExports(source, filePath) {
220
- const ts = await loadTypeScript()
197
+ async function parseTemplateSource(source, filePath) {
198
+ const ts = await loadTypeScriptFor(rootDir)
221
199
  // Parsed as its own kind of file: a TSX parse reads TypeScript-only syntax in a
222
200
  // .ts template (`<T>(props) => ...`, `<Props>value`) as JSX and loses what follows
223
201
  const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.getScriptKindFromFileName(filePath))
224
202
 
225
- // The parser recovers from a syntax error by skipping code, exports included. A
226
- // template Next.js cannot parse either is reported rather than read in part. One
227
- // it can is read from what TypeScript recovered, with a warning: Next.js compiles
228
- // templates with SWC, which may know syntax the installed TypeScript does not (a
229
- // source phase import, for one).
230
203
  const [parseError] = sourceFile.parseDiagnostics ?? []
231
204
  if (parseError) {
232
205
  const { line } = sourceFile.getLineAndCharacterOfPosition(parseError.start ?? 0)
@@ -240,6 +213,29 @@ export async function extractRouteExports(source, filePath) {
240
213
  )
241
214
  }
242
215
 
216
+ return { ts, sourceFile }
217
+ }
218
+
219
+ /**
220
+ * Split a parsed theme template's route-level exports into:
221
+ * - `segmentConfig`: segment config keys that resolve to a literal value the
222
+ * generated route file can re-declare (`{ name: value }`).
223
+ * - `moduleExports`: exported names (functions, `metadata`, etc.) the
224
+ * generated route file can safely forward with `export { ... } from`.
225
+ * - `hasDefaultExport`: whether it exports a component as default, read from
226
+ * the syntax tree so a comment or string mentioning `export default` doesn't
227
+ * count and `export { Layout as default }` does.
228
+ *
229
+ * - `errors`: one per segment config key that IS exported but not as a
230
+ * literal `export const` (an expression, an identifier, `let`/`var`, a
231
+ * re-export, or a value the Next.js schema rejects). Next.js would ignore
232
+ * such a value and fall back to its default, and nothing in the build would
233
+ * say so beyond a console warning, so a route file generated from the
234
+ * template must not be written with it. They are returned rather than
235
+ * thrown because a template whose app route already exists gets no route
236
+ * file, and nothing reads its segment config.
237
+ */
238
+ function collectRouteExports(sourceFile, ts, filePath) {
243
239
  const segmentConfig = {}
244
240
  const moduleExports = []
245
241
  const seenModuleExports = new Set()
@@ -324,41 +320,55 @@ export async function extractRouteExports(source, filePath) {
324
320
  }
325
321
  }
326
322
 
323
+ return { segmentConfig, moduleExports, hasDefaultExport: declaresDefaultExport(sourceFile, ts), errors }
324
+ }
325
+
326
+ /**
327
+ * Parse a theme template's source and return its route-level exports as
328
+ * `collectRouteExports` splits them, throwing on segment config Next.js
329
+ * wouldn't read.
330
+ */
331
+ export async function extractRouteExports(source, filePath) {
332
+ const { ts, sourceFile } = await parseTemplateSource(source, filePath)
333
+ const { errors, ...routeExports } = collectRouteExports(sourceFile, ts, filePath)
334
+
327
335
  if (errors.length > 0) {
328
- throw new Error(errors.join('\n'))
336
+ throw errorWithLines(errors)
329
337
  }
330
338
 
331
- return { segmentConfig, moduleExports, hasDefaultExport: declaresDefaultExport(sourceFile, ts) }
339
+ return routeExports
332
340
  }
333
341
 
334
342
  /**
335
343
  * Resolve a `@/contents/...` template import path to its file on disk,
336
344
  * trying both TSX and TS extensions.
337
345
  */
338
- function resolveTemplateFilePath(templatePath) {
346
+ function resolveTemplateFilePath(templatePath, root) {
339
347
  const baseTemplatePath = templatePath
340
- .replace('@/', rootDir + '/')
348
+ .replace('@/', root + '/')
341
349
  .replace(/\.(tsx|ts)$/, '')
342
350
  return existsSync(baseTemplatePath + '.tsx') ? baseTemplatePath + '.tsx' : baseTemplatePath + '.ts'
343
351
  }
344
352
 
345
353
  /**
346
- * Read a template file and extract its route-level exports. A missing file
347
- * is treated as having none, since a build-time override may point at a
348
- * template that hasn't been generated yet; a template that fails to parse
349
- * its segment config is not swallowed the same way, and propagates instead.
354
+ * Read a template file and collect its route-level exports. A missing file
355
+ * is treated as having none and as having a default export - a build-time
356
+ * override may point at a template that hasn't been generated yet, and
357
+ * assuming a component exists there keeps that case importing it rather than
358
+ * silently downgrading to metadata-only.
350
359
  */
351
- async function readRouteExports(templatePath) {
352
- const absoluteTemplatePath = resolveTemplateFilePath(templatePath)
360
+ async function readTemplateExports(templatePath, root) {
361
+ const absoluteTemplatePath = resolveTemplateFilePath(templatePath, root)
353
362
 
354
363
  let templateContent
355
364
  try {
356
365
  templateContent = await readFile(absoluteTemplatePath, 'utf8')
357
366
  } catch {
358
- return { segmentConfig: {}, moduleExports: [], hasDefaultExport: false }
367
+ return { segmentConfig: {}, moduleExports: [], hasDefaultExport: true, errors: [] }
359
368
  }
360
369
 
361
- return extractRouteExports(templateContent, absoluteTemplatePath)
370
+ const { ts, sourceFile } = await parseTemplateSource(templateContent, absoluteTemplatePath)
371
+ return collectRouteExports(sourceFile, ts, absoluteTemplatePath)
362
372
  }
363
373
 
364
374
  /**
@@ -382,12 +392,10 @@ function renderModuleExportsBlock(moduleExports, templatePathWithoutExtension) {
382
392
  /**
383
393
  * Generate content for a regular page (page.tsx, error.tsx, etc.)
384
394
  */
385
- async function generateRegularPageContent(appPath, templatePath) {
395
+ function generateRegularPageContent(appPath, templatePath, { segmentConfig, moduleExports }) {
386
396
  // Remove .tsx/.ts extension from import path (TypeScript doesn't allow file extensions in imports)
387
397
  const templatePathWithoutExtension = templatePath.replace(/\.(tsx|ts)$/, '')
388
398
 
389
- const { segmentConfig, moduleExports } = await readRouteExports(templatePath)
390
-
391
399
  const segmentConfigBlock = renderSegmentConfigBlock(segmentConfig)
392
400
  const segmentConfigSection = segmentConfigBlock
393
401
  ? `\n// Segment config re-declared as literals: Next.js only reads these when they're declared directly in the route file, not re-exported\n${segmentConfigBlock}`
@@ -413,27 +421,14 @@ ${segmentConfigSection}${moduleExportsSection}`
413
421
  /**
414
422
  * Generate content for a layout page
415
423
  */
416
- async function generateLayoutPageContent(appPath, componentName, templatePath) {
424
+ function generateLayoutPageContent(appPath, componentName, templatePath, routeExports) {
417
425
  if (templatePath) {
418
426
  // Remove .tsx/.ts extension from import path (TypeScript doesn't allow file extensions in imports)
419
427
  const templatePathWithoutExtension = templatePath.replace(/\.(tsx|ts)$/, '')
420
- const absoluteTemplatePath = resolveTemplateFilePath(templatePath)
421
428
 
422
- // Check if template file has a default export (component), and what
429
+ // Whether the template has a default export (component), and what
423
430
  // route-level exports it defines alongside (or instead of) it
424
- let templateContent = null
425
- try {
426
- templateContent = await readFile(absoluteTemplatePath, 'utf8')
427
- } catch {
428
- // Can't read the file - fall through to the default-export assumption below
429
- }
430
-
431
- let hasDefaultExport = true
432
- let segmentConfig = {}
433
- let moduleExports = []
434
- if (templateContent !== null) {
435
- ;({ segmentConfig, moduleExports, hasDefaultExport } = await extractRouteExports(templateContent, absoluteTemplatePath))
436
- }
431
+ const { hasDefaultExport, segmentConfig, moduleExports } = routeExports
437
432
 
438
433
  const segmentConfigBlock = renderSegmentConfigBlock(segmentConfig)
439
434
  const moduleExportsBlock = renderModuleExportsBlock(moduleExports, templatePathWithoutExtension)
@@ -456,9 +451,17 @@ export default TemplateComponent
456
451
  ${forwardedExportsSection}`
457
452
  } else if (segmentConfigBlock || moduleExportsBlock) {
458
453
  // No component, but the template still defines segment config and/or metadata -
459
- // re-export those and provide a pass-through component
454
+ // re-export those and provide a pass-through component. Only a PROTECTED_RENDER
455
+ // path is labeled as one; anywhere else the template simply has no default export.
456
+ const renderProtected = getProtectionLevel(appPath) === ProtectionLevel.PROTECTED_RENDER
457
+ const description = renderProtected
458
+ ? 'metadata-only (PROTECTED_RENDER)'
459
+ : 'route-level exports only (the theme template has no default export)'
460
+ const passThroughNote = renderProtected
461
+ ? 'actual rendering blocked by PROTECTED_RENDER'
462
+ : 'the theme template exports no component, so this layout renders its children'
460
463
  return `/**
461
- * Layout template - metadata-only (PROTECTED_RENDER)
464
+ * Layout template - ${description}
462
465
  * Template: ${appPath}
463
466
  * Generated by: scripts/build-registry.mjs
464
467
  */
@@ -466,7 +469,7 @@ import type { ReactNode } from 'react'
466
469
 
467
470
  // Re-export Next.js route-level exports from the theme template
468
471
  ${segmentConfigBlock}${moduleExportsBlock}
469
- // Pass-through component (actual rendering blocked by PROTECTED_RENDER)
472
+ // Pass-through component (${passThroughNote})
470
473
  interface ${componentName}Props {
471
474
  children: ReactNode
472
475
  }
@@ -565,61 +568,29 @@ function addTemplateResolverToLayout(content, layoutPath) {
565
568
  }
566
569
 
567
570
  /**
568
- * Duplicate a layout file to (templates) directory
569
- * Uses BUILD-TIME resolution: checks if template override exists and generates direct import
570
- * NO runtime resolver needed - all decisions made at build time
571
- *
572
- * @param {string} sourcePath - Path to original layout in app/
573
- * @param {string} targetPath - Path to write in app/(templates)/
574
- * @param {string} layoutPath - Relative layout path (e.g., 'app/dashboard/layout.tsx')
575
- * @param {Array} templates - All discovered templates (for build-time override check)
576
- */
577
- async function duplicateLayoutDirect(sourcePath, targetPath, layoutPath, templates) {
578
- // Create target directory if it doesn't exist
579
- const targetDir = dirname(targetPath)
580
- await mkdir(targetDir, { recursive: true })
581
-
582
- // BUILD-TIME CHECK: Does a template override exist for this layout?
583
- const layoutOverride = templates.find(t =>
584
- t.appPath === layoutPath && t.templateType === 'layout'
585
- )
586
-
587
- if (layoutOverride) {
588
- // Template override exists - generate direct import to it
589
- const templatePathWithoutExtension = layoutOverride.templatePath.replace(/\.(tsx|ts)$/, '')
590
- const componentName = `Layout${toPascalCase(layoutPath.replace(/[^\w]/g, '_'))}`
591
-
592
- const { segmentConfig, moduleExports } = await readRouteExports(layoutOverride.templatePath)
593
- const segmentConfigBlock = renderSegmentConfigBlock(segmentConfig)
594
- const moduleExportsBlock = renderModuleExportsBlock(moduleExports, templatePathWithoutExtension)
595
- const forwardedExportsSection = segmentConfigBlock || moduleExportsBlock
596
- ? `\n// Re-export Next.js route-level exports from the theme template\n${segmentConfigBlock}${moduleExportsBlock}`
597
- : ''
598
-
599
- const content = `/**
600
- * Layout with template override - directly imports from theme
601
- * Template: ${layoutPath}
602
- * Override: ${layoutOverride.templatePath}
603
- * Generated by: scripts/build-registry.mjs (build-time resolution)
571
+ * The comment a copy of an app/ layout in app/(templates)/ starts with. A
572
+ * comment is not a statement, so a 'use client' directive right after it is
573
+ * still the file's directive.
604
574
  */
605
- import TemplateLayout from '${templatePathWithoutExtension}'
606
-
607
- export default TemplateLayout
608
- ${forwardedExportsSection}`
609
- await writeFile(targetPath, content, 'utf8')
610
- verbose(`Generated layout with template override: ${layoutPath}`)
611
- return
612
- }
575
+ function layoutCopyHeader(layoutPath) {
576
+ return `/**
577
+ * Generated by the NextSpark registry build: a copy of ${layoutPath}, so theme
578
+ * routes under app/(templates) render inside the same layout. Rewritten on every build.
579
+ */
580
+ `
581
+ }
613
582
 
614
- // No override - copy original layout as-is (simple pass-through)
615
- try {
616
- const originalContent = await readFile(sourcePath, 'utf8')
617
- await writeFile(targetPath, originalContent, 'utf8')
618
- verbose(`Copied original layout: ${layoutPath}`)
619
- } catch (error) {
620
- // If reading fails, generate a basic pass-through wrapper
583
+ /**
584
+ * The content of the copy of an app/ layout in app/(templates)/. A layout that
585
+ * can't be read is replaced by a pass-through, which keeps the hierarchy.
586
+ */
587
+ async function layoutCopyContent(layoutPath, appFiles) {
588
+ const layout = await projectFileContent(layoutPath, appFiles)
589
+ if (layout !== null) {
590
+ return layoutCopyHeader(layoutPath) + layout
591
+ } else {
621
592
  const componentName = `PassThroughLayout${toPascalCase(layoutPath.replace(/[^\w]/g, '_'))}`
622
- const basicContent = `/**
593
+ return `/**
623
594
  * Basic layout wrapper - maintains Next.js structure
624
595
  * Generated by: scripts/build-registry.mjs
625
596
  */
@@ -631,74 +602,206 @@ export default function ${componentName}({ children }: ${componentName}Props) {
631
602
  return <>{children}</>
632
603
  }
633
604
  `
634
- await writeFile(targetPath, basicContent, 'utf8')
635
- verbose(`Generated basic layout wrapper for ${layoutPath}`)
636
605
  }
637
606
  }
638
607
 
639
608
  /**
640
- * Generate required parent layouts for a template page
641
- * Duplicates layouts from app/ to app/(templates)/ to preserve Next.js hierarchy
642
- * Uses BUILD-TIME resolution to check for template overrides
609
+ * The app/ layouts above a route directory, outermost first, that exist in the
610
+ * project: the ones a route generated into app/(templates)/ needs copied beside
611
+ * it to render inside the same hierarchy.
643
612
  *
644
- * @param {string} appPath - App path (e.g., 'app/dashboard/(main)/posts/page.tsx')
645
- * @param {string} templatesDir - Output directory for (templates)
646
- * @param {Array} templates - All discovered templates (for build-time override check)
613
+ * @param {string} directory - App directory of a route (e.g. 'app/dashboard/(main)/posts')
647
614
  */
648
- async function generateRequiredLayouts(appPath, templatesDir, templates) {
649
- let layoutsGenerated = 0
650
-
651
- // Extract the directory path from app path (remove filename)
652
- // Example: app/dashboard/(main)/plugins/page.tsx -> dashboard/(main)/plugins
653
- const pathParts = appPath.replace('app/', '').replace(/\/[^\/]+$/, '').split('/')
654
-
655
- // Generate all parent directories and check for layouts
615
+ function requiredLayoutPaths(directory, appFiles) {
616
+ const layoutPaths = []
656
617
  let currentPath = ''
657
- for (const part of pathParts) {
618
+ for (const part of directory.replace(/^app\/?/, '').split('/')) {
658
619
  if (!part) continue
659
-
660
620
  currentPath = currentPath ? `${currentPath}/${part}` : part
661
621
  const layoutPath = `app/${currentPath}/layout.tsx`
662
- const coreLayoutPath = join(rootDir, layoutPath)
663
- const templateLayoutPath = join(templatesDir, `${currentPath}/layout.tsx`)
664
-
665
- // Check if core layout exists and template layout doesn't
666
- if (existsSync(coreLayoutPath) && !existsSync(templateLayoutPath)) {
667
- // Pass templates for build-time override resolution
668
- await duplicateLayoutDirect(coreLayoutPath, templateLayoutPath, layoutPath, templates)
669
- layoutsGenerated++
622
+ const exists = appFiles?.has(layoutPath) ? appFiles.get(layoutPath) !== null : existsSync(join(rootDir, layoutPath))
623
+ if (exists) {
624
+ layoutPaths.push(layoutPath)
670
625
  }
671
626
  }
627
+ return layoutPaths
628
+ }
672
629
 
673
- return layoutsGenerated
630
+ /**
631
+ * A file's content, by path from the project root: what `appFiles` gives when
632
+ * it has the path - null standing for a file that is gone - and otherwise what
633
+ * is on disk; null when there is no such file.
634
+ */
635
+ async function projectFileContent(path, appFiles) {
636
+ if (appFiles?.has(path)) return appFiles.get(path)
637
+ try {
638
+ return await readFile(join(rootDir, path), 'utf8')
639
+ } catch {
640
+ return null
641
+ }
674
642
  }
675
643
 
676
644
  /**
677
645
  * Generate a single template page with template override system
646
+ *
647
+ * What `routeFileAction` decides applies: a template over a route the app
648
+ * already has gets no file, and the result says so; one whose route file would
649
+ * break is rejected before anything is written.
650
+ *
651
+ * @param {Object} template - The template to generate a route file from
652
+ * @param {string} outputPath - Where to write it
653
+ * @param {Map} [analysis] - What `analyzeTemplates` read from the build's templates; taken for this
654
+ * template when omitted
655
+ * @returns {Promise<{ written: boolean, reason?: string }>}
678
656
  */
679
- export async function generateTemplatePage(template, outputPath) {
680
- const { appPath, templateType, name, templatePath } = template
657
+ export async function generateTemplatePage(template, outputPath, analysis = null) {
658
+ const routeExports = analysis ? templateAnalysisFor(analysis, template) : await analyzeTemplate(template, rootDir)
681
659
 
682
- // Create directory if it doesn't exist
683
- const dir = dirname(outputPath)
684
- await mkdir(dir, { recursive: true })
660
+ if (routeFileAction(template, routeExports) === 'skip') {
661
+ return { written: false, reason: `the app already has ${template.appPath}, so no route file is generated for this template` }
662
+ }
685
663
 
686
- // Determine the component name and content based on template type
687
- const routeSegments = name.split('/').filter(segment => !segment.startsWith('[') && !segment.endsWith(']'))
688
- const routeName = routeSegments[routeSegments.length - 1] || 'page'
689
- const componentName = `AutoGenerated${toPascalCase(routeName)}${toPascalCase(templateType)}`
664
+ const files = projectFiles(rootDir)
665
+ await files.mkdir(dirname(outputPath), { recursive: true })
666
+ await files.writeFile(outputPath, routeFileContent(template, routeExports), 'utf8')
667
+ verbose(`Generated: ${outputPath.replace(rootDir, '')}`)
668
+ return { written: true }
669
+ }
690
670
 
691
- let content
671
+ /**
672
+ * Whether `generateMissingPages` will write a route file at `appPath` -
673
+ * importing the template's default export - rather than leaving the app's
674
+ * own file in place and letting the override resolve at runtime through
675
+ * `getTemplateOrDefault`. A layout is always written, since build-time
676
+ * override resolution duplicates it into `app/(templates)/` regardless of
677
+ * whether the app already has one; anything else is only written when the
678
+ * app doesn't already have that route.
679
+ *
680
+ * @param {Map<string, string | null>} [appFiles] - Files under app/, by path from the project root, to take as
681
+ * having this content instead of what is on disk (null: removed)
682
+ */
683
+ export function willGenerateRoute(appPath, templateType, root = rootDir, appFiles = undefined) {
684
+ if (templateType === 'layout') return true
685
+ if (appFiles?.has(appPath)) return appFiles.get(appPath) === null
686
+ return !existsSync(join(root, appPath))
687
+ }
692
688
 
693
- if (templateType === 'layout') {
694
- content = await generateLayoutPageContent(appPath, componentName, templatePath)
695
- } else {
696
- content = await generateRegularPageContent(appPath, templatePath)
689
+ /**
690
+ * Parse each template once and record what it contributes to the build: its
691
+ * route-level exports, whether it has a default export, and whether the page
692
+ * generator writes a route file for it (`generatesRoute`). The server template
693
+ * registry, the client template registry and the page generator all take
694
+ * these from the result instead of reading the template themselves, so a
695
+ * template that changes while a build runs can't look one way to one of them
696
+ * and another way to the next.
697
+ *
698
+ * Throws for a template the page generator writes a route file for when that
699
+ * file can't work: segment config Next.js wouldn't read, or a page (or any
700
+ * other non-layout route) with no default export to import - a plain .tsx/.ts
701
+ * as much as a standalone .meta.ts, which never has one. A layout with no
702
+ * default export gets a pass-through component instead. A template whose app
703
+ * route already exists gets no route file, so neither applies to it: the
704
+ * override resolves at runtime, metadata included.
705
+ *
706
+ * @param {Array} templates - List of template definitions
707
+ * @param {Object} config - Configuration object with projectRoot
708
+ * @param {Map<string, string | null>} [appFiles] - Files under app/, by path from the project root, to take as
709
+ * having this content instead of what is on disk (null: removed), so `generatesRoute` answers for the app as
710
+ * a sync about to run leaves it rather than for the app the analysis runs against
711
+ * @returns {Promise<Map<string, Object>>} Each template's analysis, keyed by its templatePath
712
+ */
713
+ export async function analyzeTemplates(templates, config = null, appFiles = undefined) {
714
+ if (config?.projectRoot) {
715
+ rootDir = config.projectRoot
697
716
  }
698
717
 
699
- // Write the file
700
- await writeFile(outputPath, content, 'utf8')
701
- verbose(`Generated: ${outputPath.replace(rootDir, '')}`)
718
+ const analysis = new Map()
719
+
720
+ for (const template of templates) {
721
+ if (!analysis.has(template.templatePath)) {
722
+ analysis.set(template.templatePath, await analyzeTemplate(template, rootDir, appFiles))
723
+ }
724
+ }
725
+
726
+ return analysis
727
+ }
728
+
729
+ /**
730
+ * Parse one template and collect what `analyzeTemplates` records for it,
731
+ * rejecting it when the route file generated from it can't work: segment
732
+ * config Next.js wouldn't read, or what `routeFileAction` rejects. Every
733
+ * generator that has no analysis to hand reads a template through here.
734
+ *
735
+ * @param {Object} template - The template to read
736
+ * @param {string} root - The project root its path resolves against
737
+ * @param {Map<string, string | null>} [appFiles] - Files under app/ to take as having this content, as for
738
+ * `analyzeTemplates`
739
+ */
740
+ async function analyzeTemplate(template, root, appFiles) {
741
+ const { appPath, templateType, templatePath } = template
742
+ const generatesRoute = willGenerateRoute(appPath, templateType, root, appFiles)
743
+ const { errors, ...routeExports } = await readTemplateExports(templatePath, root)
744
+
745
+ if (generatesRoute && errors.length > 0) {
746
+ throw errorWithLines(errors)
747
+ }
748
+
749
+ const entry = { ...routeExports, generatesRoute }
750
+ routeFileAction(template, entry)
751
+ return entry
752
+ }
753
+
754
+ /**
755
+ * What becomes of a template's route file, decided from its analysis in this
756
+ * one place for every generator that writes route files or registers template
757
+ * components:
758
+ * - 'write': a route file is generated for it in app/(templates)
759
+ * - 'skip': the app already has that route, which stays; the override resolves
760
+ * at runtime, and nothing is written for it
761
+ *
762
+ * A page (or any other non-layout route) whose route file would import a default
763
+ * export it doesn't have - a plain .tsx/.ts as much as a standalone .meta.ts,
764
+ * which never has one - is rejected. A layout with no default export gets a
765
+ * pass-through component instead.
766
+ *
767
+ * @param {Object} template - The template
768
+ * @param {Object} entry - Its analysis, as `analyzeTemplates` records it
769
+ * @returns {'write' | 'skip'}
770
+ */
771
+ export function routeFileAction(template, entry) {
772
+ const { appPath, templateType, templatePath } = template
773
+
774
+ if (!entry.generatesRoute) {
775
+ return 'skip'
776
+ }
777
+
778
+ if (templateType !== 'layout' && !entry.hasDefaultExport) {
779
+ throw new Error(
780
+ `${templatePath} has no default export, and the app has no existing route at "${appPath}" ` +
781
+ `for a metadata-only override to attach to - the registry build generates "${appPath}" importing this ` +
782
+ `template's default export, which doesn't exist. A page template needs a default export. For a ` +
783
+ `metadata-only override, add one to an app page that already exists at "${appPath}" (a ".meta.ts" file, ` +
784
+ `or a template with no default export, next to it).`
785
+ )
786
+ }
787
+
788
+ return 'write'
789
+ }
790
+
791
+ /**
792
+ * The analysis `analyzeTemplates` recorded for a template. Missing means the
793
+ * caller is generating from templates other than the ones it analyzed, and
794
+ * reading the template here would bring back a second, possibly different,
795
+ * answer - so it fails instead.
796
+ */
797
+ export function templateAnalysisFor(analysis, template) {
798
+ const entry = analysis?.get(template.templatePath)
799
+ if (!entry) {
800
+ throw new Error(
801
+ `${template.templatePath} was not read by analyzeTemplates() - pass the analysis of the same templates being generated.`
802
+ )
803
+ }
804
+ return entry
702
805
  }
703
806
 
704
807
  /**
@@ -708,115 +811,256 @@ export async function generateTemplatePage(template, outputPath) {
708
811
  *
709
812
  * @param {Array} templates - List of template definitions
710
813
  * @param {Object} config - Configuration object with projectRoot
814
+ * @param {Map} [analysis] - What `analyzeTemplates` read from these templates; taken for this call when omitted
815
+ * @returns {Promise<{ created: number, updated: number, removed: number, backupDir: string | null, skipped: string[] }>}
816
+ * What changed in app/(templates), and the app paths of the templates that got no route file
711
817
  */
712
- export async function generateMissingPages(templates, config = null) {
818
+ export async function generateMissingPages(templates, config = null, analysis = null) {
713
819
  // Use config.projectRoot if provided, otherwise fall back to default rootDir
714
820
  if (config?.projectRoot) {
715
821
  rootDir = config.projectRoot
716
822
  }
717
823
  const templatesDir = join(rootDir, 'app', '(templates)')
718
824
 
719
- // First, clean up orphaned files before generating new ones
720
- // Note: This is also called in registry.mjs before generateMissingPages,
721
- // but we keep it here for standalone usage of this function
722
- await cleanupOrphanedTemplates(templates, config)
825
+ // Every template has to pass routeFileAction before anything is written or deleted
826
+ analysis = analysis ?? (await analyzeTemplates(templates, config))
827
+ const skipped = templates
828
+ .filter(template => routeFileAction(template, templateAnalysisFor(analysis, template)) === 'skip')
829
+ .map(({ appPath }) => appPath)
830
+
831
+ const { created, updated, removed, backupDir } = await reconcileTemplatesTree(
832
+ templatesDir,
833
+ await planTemplatesTree(templates, analysis)
834
+ )
723
835
 
724
- if (templates.length === 0) {
725
- return
836
+ if (created > 0 || updated > 0 || removed > 0) {
837
+ log(`app/(templates): ${created} new, ${updated} updated, ${removed} removed`, 'success')
838
+ } else {
839
+ verbose('app/(templates) is up to date')
840
+ }
841
+ if (backupDir) {
842
+ log(`app/(templates): what was replaced or removed is backed up in ${relative(rootDir, backupDir)}`, 'warning')
726
843
  }
727
844
 
728
- let generated = 0
729
- let layoutsDuplicated = 0
845
+ return { created, updated, removed, backupDir, skipped }
846
+ }
730
847
 
731
- // Track all unique directory paths that need layouts
732
- const layoutPathsNeeded = new Set()
848
+ /**
849
+ * What `generateMissingPages` would change in app/(templates), worked out
850
+ * without writing anything: the files it would create, the ones it would
851
+ * replace and the ones it would remove - backing up both of those first - as
852
+ * paths from the project root.
853
+ *
854
+ * `appFiles` reaches the analysis too: whether a template gets a route file at
855
+ * all turns on whether the app has one at that path, so a route the sync is
856
+ * about to write - or remove - decides the plan the same way it decides the run.
857
+ *
858
+ * @param {Array} templates - List of template definitions
859
+ * @param {Object} config - Configuration object with projectRoot
860
+ * @param {Map<string, string | null>} [appFiles] - Files under app/, by path from the project root, to take as
861
+ * having this content instead of what is on disk (null: removed), to plan against changes not written yet
862
+ * @returns {Promise<{ create: string[], replace: string[], remove: string[] }>}
863
+ */
864
+ export async function planMissingPages(templates, config = null, appFiles = undefined) {
865
+ if (config?.projectRoot) {
866
+ rootDir = config.projectRoot
867
+ }
868
+ const templatesDir = join(rootDir, 'app', '(templates)')
733
869
 
734
- // Group templates by their directory path to identify needed layouts
735
- const templatesByPath = new Map()
870
+ const analysis = await analyzeTemplates(templates, config, appFiles)
871
+ const { create, replace, remove } = await diffTemplatesTree(templatesDir, await planTemplatesTree(templates, analysis, appFiles))
736
872
 
737
- for (const template of templates) {
738
- const { appPath, templateType } = template
739
- const templatePagePath = join(rootDir, 'app', '(templates)', appPath.replace('app/', ''))
873
+ const fromRoot = path => relative(rootDir, path).split(sep).join('/')
874
+ return { create: create.map(fromRoot), replace: replace.map(fromRoot), remove: remove.map(fromRoot) }
875
+ }
740
876
 
741
- // Track the directory path for layout generation
742
- layoutPathsNeeded.add(appPath)
877
+ /**
878
+ * The route file generated from a template, with its component named after the
879
+ * last static segment of the template's route.
880
+ */
881
+ function routeFileContent(template, routeExports) {
882
+ const { appPath, templateType, name, templatePath } = template
883
+ const routeSegments = name.split('/').filter(segment => !segment.startsWith('[') && !segment.endsWith(']'))
884
+ const routeName = routeSegments[routeSegments.length - 1] || 'page'
885
+ const componentName = `AutoGenerated${toPascalCase(routeName)}${toPascalCase(templateType)}`
743
886
 
744
- // Check if core page exists (skip generation if it does)
745
- // IMPORTANT: Always generate layouts, even if core layout exists
746
- const corePagePath = join(rootDir, appPath)
747
- if (existsSync(corePagePath) && templateType !== 'layout') {
748
- continue
749
- }
887
+ return templateType === 'layout'
888
+ ? generateLayoutPageContent(appPath, componentName, templatePath, routeExports)
889
+ : generateRegularPageContent(appPath, templatePath, routeExports)
890
+ }
891
+
892
+ /** Where a file under app/ is generated inside app/(templates)/. */
893
+ function templatesTreePath(appPath) {
894
+ return join(rootDir, 'app', '(templates)', appPath.replace(/^app\//, ''))
895
+ }
750
896
 
751
- // Track template for potential generation/update
752
- templatesByPath.set(appPath, { template, templatePagePath })
897
+ /**
898
+ * Everything the registry build puts in app/(templates), as absolute path to
899
+ * content: a copy of each app/ layout above a template's route, and the route
900
+ * file of each template that gets one. A layout template's route file takes the
901
+ * place of the copy of the app layout at the same path.
902
+ */
903
+ async function planTemplatesTree(templates, analysis, appFiles) {
904
+ const files = new Map()
905
+
906
+ const directories = new Set(templates.map(({ appPath }) => appPath.replace(/\/[^\/]+$/, '')))
907
+ for (const directory of directories) {
908
+ for (const layoutPath of requiredLayoutPaths(directory, appFiles)) {
909
+ const copyPath = templatesTreePath(layoutPath)
910
+ if (!files.has(copyPath)) {
911
+ files.set(copyPath, await layoutCopyContent(layoutPath, appFiles))
912
+ }
913
+ }
753
914
  }
754
915
 
755
- // Generate required layouts for ALL template page directories (even if layout override doesn't exist)
756
- // This ensures that pages in app/(templates)/ have the necessary layout hierarchy
757
- // Extract unique directories from all templates (not just layout templates)
758
- const uniqueDirectories = new Set()
759
916
  for (const template of templates) {
760
- const { appPath } = template
761
- // Extract directory path from page path
762
- const dirPath = appPath.replace(/\/[^\/]+$/, '')
763
- uniqueDirectories.add(dirPath)
917
+ const routeExports = templateAnalysisFor(analysis, template)
918
+ if (routeFileAction(template, routeExports) === 'write') {
919
+ files.set(templatesTreePath(template.appPath), routeFileContent(template, routeExports))
920
+ }
764
921
  }
765
922
 
766
- for (const dirPath of uniqueDirectories) {
767
- // Pass templates for build-time override resolution
768
- const layoutsGenerated = await generateRequiredLayouts(`${dirPath}/page.tsx`, templatesDir, templates)
769
- layoutsDuplicated += layoutsGenerated
923
+ return files
924
+ }
925
+
926
+ /**
927
+ * Every file under a directory, recursively, dotfiles included; none when it
928
+ * doesn't exist. Finder's .DS_Store is nobody's output and nobody's edit, and
929
+ * is left alone.
930
+ */
931
+ async function listFiles(directory) {
932
+ if (!existsSync(directory)) return []
933
+
934
+ const files = []
935
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
936
+ if (entry.name === '.DS_Store') continue
937
+ const path = join(directory, entry.name)
938
+ if (entry.isDirectory()) {
939
+ files.push(...(await listFiles(path)))
940
+ } else if (entry.isFile()) {
941
+ files.push(path)
942
+ }
770
943
  }
944
+ return files
945
+ }
771
946
 
772
- // Generate or update template pages (regenerate if content differs - e.g., theme changed)
773
- let updated = 0
774
- for (const [appPath, { template, templatePagePath }] of templatesByPath) {
775
- const { templateType, name, templatePath } = template
947
+ /**
948
+ * Remove a directory the tree needs a file in place of, once the files in it
949
+ * are backed up and removed: the directories left in it, and any .DS_Store.
950
+ */
951
+ async function removeDirectoryInTheWay(directory) {
952
+ const files = projectFiles(rootDir)
953
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
954
+ const path = join(directory, entry.name)
955
+ if (entry.isDirectory()) {
956
+ await removeDirectoryInTheWay(path)
957
+ } else if (entry.name === '.DS_Store') {
958
+ await files.unlink(path)
959
+ }
960
+ }
961
+ await files.rmdir(directory)
962
+ }
776
963
 
777
- // Generate expected content
778
- const routeSegments = name.split('/').filter(segment => !segment.startsWith('[') && !segment.endsWith(']'))
779
- const routeName = routeSegments[routeSegments.length - 1] || 'page'
780
- const componentName = `AutoGenerated${toPascalCase(routeName)}${toPascalCase(templateType)}`
964
+ /** Remove the empty directories under `directory`, deepest first, keeping `directory` itself. */
965
+ async function removeEmptyDirectories(directory) {
966
+ if (!existsSync(directory)) return
781
967
 
782
- let expectedContent
783
- if (templateType === 'layout') {
784
- expectedContent = await generateLayoutPageContent(appPath, componentName, templatePath)
785
- } else {
786
- expectedContent = await generateRegularPageContent(appPath, templatePath)
968
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
969
+ if (!entry.isDirectory()) continue
970
+ const path = join(directory, entry.name)
971
+ await removeEmptyDirectories(path)
972
+ if ((await readdir(path)).length === 0) {
973
+ await projectFiles(rootDir).rmdir(path)
787
974
  }
975
+ }
976
+ }
788
977
 
789
- // Check if file exists and compare content
790
- if (existsSync(templatePagePath)) {
791
- try {
792
- const currentContent = await readFile(templatePagePath, 'utf8')
793
- if (currentContent === expectedContent) {
794
- // Content matches, skip
795
- continue
796
- }
797
- // Content differs (theme changed), update file
798
- await writeFile(templatePagePath, expectedContent, 'utf8')
799
- updated++
800
- verbose(`Updated (theme changed): ${templatePagePath.replace(rootDir, '')}`)
801
- } catch (error) {
802
- // Error reading, regenerate
803
- await generateTemplatePage(template, templatePagePath)
804
- generated++
805
- }
806
- } else {
807
- // File doesn't exist, generate
808
- await generateTemplatePage(template, templatePagePath)
809
- generated++
978
+ /**
979
+ * How app/(templates) differs from `files`, as absolute paths: the files
980
+ * `files` has that don't exist yet - a directory where one goes counts as none,
981
+ * since the files in it are removed - the ones that exist with other content,
982
+ * and the files in the tree that `files` doesn't have.
983
+ */
984
+ async function diffTemplatesTree(templatesDir, files) {
985
+ const create = []
986
+ const replace = []
987
+ for (const [path, content] of files) {
988
+ if (!existsSync(path) || (await lstat(path)).isDirectory()) {
989
+ create.push(path)
990
+ } else if ((await readFile(path, 'utf8')) !== content) {
991
+ replace.push(path)
810
992
  }
811
993
  }
812
994
 
813
- if (generated > 0 || updated > 0 || layoutsDuplicated > 0) {
814
- const parts = []
815
- if (generated > 0) parts.push(`${generated} new`)
816
- if (updated > 0) parts.push(`${updated} updated`)
817
- if (layoutsDuplicated > 0) parts.push(`${layoutsDuplicated} layouts`)
818
- log(`Template pages: ${parts.join(', ')}`, 'success')
819
- } else {
820
- verbose('All template pages up to date')
995
+ const remove = (await listFiles(templatesDir)).filter(path => !files.has(path))
996
+ return { create, replace, remove }
997
+ }
998
+
999
+ /**
1000
+ * Make app/(templates) hold exactly `contents`. The registry build owns that tree,
1001
+ * but a file about to be replaced with different content, or removed because
1002
+ * this build didn't produce it, may hold someone's edit: it is first copied into
1003
+ * a directory under .nextspark/backups/ that belongs to this run alone - the
1004
+ * time, and a suffix no other run gets - and named in the output. A backup is
1005
+ * never written over.
1006
+ *
1007
+ * Whatever runs the build - build, dev, registry:build or sync:app - the
1008
+ * backups are kept out of git by .nextspark/backups/.gitignore, put in place
1009
+ * before anything in the tree is written; when one already there can't do
1010
+ * that, nothing in the tree is written at all.
1011
+ */
1012
+ async function reconcileTemplatesTree(templatesDir, contents) {
1013
+ const { create, replace, remove } = await diffTemplatesTree(templatesDir, contents)
1014
+ if (replace.length > 0 || remove.length > 0) {
1015
+ await ensureBackupsGitignore(rootDir)
1016
+ }
1017
+ let backupDir = null
1018
+ const files = projectFiles(rootDir)
1019
+
1020
+ const backUp = async absolutePath => {
1021
+ if (!backupDir) {
1022
+ const backupsRoot = join(rootDir, '.nextspark', 'backups')
1023
+ await files.mkdir(backupsRoot, { recursive: true })
1024
+ backupDir = await files.mkdtemp(join(backupsRoot, `${new Date().toISOString().replace(/[:.]/g, '-')}-`))
1025
+ }
1026
+ const relativePath = relative(rootDir, absolutePath)
1027
+ const backupPath = join(backupDir, relativePath)
1028
+ await files.mkdir(dirname(backupPath), { recursive: true })
1029
+ await files.copyFile(absolutePath, backupPath, constants.COPYFILE_EXCL)
1030
+ log(`app/(templates): backed up ${relativePath} to ${relative(rootDir, backupPath)}`, 'warning')
821
1031
  }
1032
+
1033
+ // What stands where a file goes - a file where the tree needs a directory,
1034
+ // or the files of a directory where it needs a file - is backed up and
1035
+ // removed before anything is created, and new files still come before the
1036
+ // rest is replaced or removed
1037
+ const inTheWay = new Set(remove.filter(path =>
1038
+ create.some(created => created.startsWith(`${path}${sep}`) || path.startsWith(`${created}${sep}`))
1039
+ ))
1040
+ for (const path of inTheWay) {
1041
+ await backUp(path)
1042
+ await files.unlink(path)
1043
+ }
1044
+
1045
+ for (const path of create) {
1046
+ if (existsSync(path)) {
1047
+ await removeDirectoryInTheWay(path)
1048
+ }
1049
+ await files.mkdir(dirname(path), { recursive: true })
1050
+ await files.writeFile(path, contents.get(path), 'utf8')
1051
+ }
1052
+
1053
+ for (const path of replace) {
1054
+ await backUp(path)
1055
+ await files.writeFile(path, contents.get(path), 'utf8')
1056
+ }
1057
+
1058
+ for (const path of remove.filter(path => !inTheWay.has(path))) {
1059
+ await backUp(path)
1060
+ await files.unlink(path)
1061
+ }
1062
+
1063
+ await removeEmptyDirectories(templatesDir)
1064
+
1065
+ return { created: create.length, updated: replace.length, removed: remove.length, backupDir }
822
1066
  }