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

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 (176) hide show
  1. package/dist/components/dashboard/block-editor/dynamic-form.d.ts.map +1 -1
  2. package/dist/components/dashboard/block-editor/dynamic-form.js +2 -2
  3. package/dist/components/dashboard/layouts/TopNavbar.js +1 -1
  4. package/dist/components/dashboard/mobile/MobileTopBar.js +3 -2
  5. package/dist/emails/otp-verification.d.ts.map +1 -1
  6. package/dist/emails/otp-verification.js +1 -1
  7. package/dist/emails/reset-password.d.ts.map +1 -1
  8. package/dist/emails/reset-password.js +1 -1
  9. package/dist/emails/team-invitation.d.ts.map +1 -1
  10. package/dist/emails/team-invitation.js +1 -1
  11. package/dist/emails/verify-email.d.ts.map +1 -1
  12. package/dist/emails/verify-email.js +1 -1
  13. package/dist/lib/actions/entity.actions.js +8 -8
  14. package/dist/lib/actions/team.actions.js +7 -7
  15. package/dist/lib/api/entity/generic-handler.js +5 -5
  16. package/dist/lib/api/permission-middleware.js +1 -1
  17. package/dist/lib/auth/registration-guard-plugin.d.ts.map +1 -1
  18. package/dist/lib/auth/registration-guard-plugin.js +2 -2
  19. package/dist/lib/auth-email-callbacks.d.ts +3 -1
  20. package/dist/lib/auth-email-callbacks.d.ts.map +1 -1
  21. package/dist/lib/auth-email-callbacks.js +8 -2
  22. package/dist/lib/auth.d.ts.map +1 -1
  23. package/dist/lib/auth.js +4 -3
  24. package/dist/lib/billing/gateways/stripe.js +1 -1
  25. package/dist/lib/entities/hooks.js +1 -1
  26. package/dist/templates/app/(auth)/accept-invite/[token]/page.tsx +1 -1
  27. package/dist/templates/app/(auth)/auth-error/page.tsx +1 -1
  28. package/dist/templates/app/(auth)/forgot-password/page.tsx +1 -1
  29. package/dist/templates/app/(auth)/layout.tsx +1 -1
  30. package/dist/templates/app/(auth)/login/page.tsx +1 -1
  31. package/dist/templates/app/(auth)/reset-password/page.tsx +1 -1
  32. package/dist/templates/app/(auth)/signup/page.tsx +1 -1
  33. package/dist/templates/app/(auth)/verify-email/page.tsx +1 -1
  34. package/dist/templates/app/(public)/[...slug]/page.tsx +3 -3
  35. package/dist/templates/app/(public)/layout.tsx +1 -1
  36. package/dist/templates/app/(public)/page.tsx +1 -1
  37. package/dist/templates/app/403/page.tsx +1 -1
  38. package/dist/templates/app/dashboard/(main)/[entity]/[id]/edit/page.tsx +1 -1
  39. package/dist/templates/app/dashboard/(main)/[entity]/[id]/page.tsx +4 -4
  40. package/dist/templates/app/dashboard/(main)/[entity]/create/page.tsx +1 -1
  41. package/dist/templates/app/dashboard/(main)/[entity]/loading.tsx +1 -1
  42. package/dist/templates/app/dashboard/(main)/[entity]/page.tsx +1 -1
  43. package/dist/templates/app/dashboard/(main)/layout.tsx +1 -1
  44. package/dist/templates/app/dashboard/(main)/loading.tsx +1 -1
  45. package/dist/templates/app/dashboard/(main)/media/page.tsx +1 -1
  46. package/dist/templates/app/dashboard/(main)/page.tsx +1 -1
  47. package/dist/templates/app/dashboard/(main)/patterns/[id]/edit/page.tsx +1 -1
  48. package/dist/templates/app/dashboard/(main)/patterns/[id]/page.tsx +1 -1
  49. package/dist/templates/app/dashboard/(main)/patterns/[id]/reports/page.tsx +1 -1
  50. package/dist/templates/app/dashboard/(main)/patterns/create/page.tsx +1 -1
  51. package/dist/templates/app/dashboard/(main)/patterns/page.tsx +1 -1
  52. package/dist/templates/app/dashboard/features/analytics/page.tsx +1 -1
  53. package/dist/templates/app/dashboard/features/automation/page.tsx +1 -1
  54. package/dist/templates/app/dashboard/features/layout.tsx +1 -1
  55. package/dist/templates/app/dashboard/features/loading.tsx +1 -1
  56. package/dist/templates/app/dashboard/features/webhooks/page.tsx +1 -1
  57. package/dist/templates/app/dashboard/layout.tsx +1 -1
  58. package/dist/templates/app/dashboard/permission-denied/page.tsx +1 -1
  59. package/dist/templates/app/dashboard/settings/api-keys/loading.tsx +1 -1
  60. package/dist/templates/app/dashboard/settings/api-keys/page.tsx +1 -1
  61. package/dist/templates/app/dashboard/settings/billing/loading.tsx +1 -1
  62. package/dist/templates/app/dashboard/settings/billing/page.tsx +1 -1
  63. package/dist/templates/app/dashboard/settings/invoices/[invoiceNumber]/page.tsx +1 -1
  64. package/dist/templates/app/dashboard/settings/invoices/loading.tsx +1 -1
  65. package/dist/templates/app/dashboard/settings/invoices/page.tsx +1 -1
  66. package/dist/templates/app/dashboard/settings/layout.tsx +1 -1
  67. package/dist/templates/app/dashboard/settings/loading.tsx +1 -1
  68. package/dist/templates/app/dashboard/settings/notifications/loading.tsx +1 -1
  69. package/dist/templates/app/dashboard/settings/notifications/page.tsx +1 -1
  70. package/dist/templates/app/dashboard/settings/page.tsx +1 -1
  71. package/dist/templates/app/dashboard/settings/password/loading.tsx +1 -1
  72. package/dist/templates/app/dashboard/settings/password/page.tsx +1 -1
  73. package/dist/templates/app/dashboard/settings/plans/loading.tsx +1 -1
  74. package/dist/templates/app/dashboard/settings/plans/page.tsx +1 -1
  75. package/dist/templates/app/dashboard/settings/profile/loading.tsx +1 -1
  76. package/dist/templates/app/dashboard/settings/profile/page.tsx +1 -1
  77. package/dist/templates/app/dashboard/settings/security/loading.tsx +1 -1
  78. package/dist/templates/app/dashboard/settings/security/page.tsx +1 -1
  79. package/dist/templates/app/dashboard/settings/teams/loading.tsx +1 -1
  80. package/dist/templates/app/dashboard/settings/teams/page.tsx +1 -1
  81. package/dist/templates/app/dashboard/settings/teams/permissions/page.tsx +1 -1
  82. package/dist/templates/app/devtools/api/[[...endpoint]]/page.tsx +1 -1
  83. package/dist/templates/app/devtools/config/page.tsx +1 -1
  84. package/dist/templates/app/devtools/layout.tsx +3 -2
  85. package/dist/templates/app/devtools/tests/[[...path]]/page.tsx +1 -1
  86. package/dist/templates/app/layout.ppr.tsx +1 -1
  87. package/dist/templates/app/layout.tsx +1 -1
  88. package/dist/templates/app/public/page.tsx +1 -1
  89. package/dist/templates/app/superadmin/layout.tsx +1 -1
  90. package/dist/templates/app/superadmin/page.tsx +1 -1
  91. package/dist/templates/app/superadmin/subscriptions/page.tsx +1 -1
  92. package/dist/templates/app/superadmin/team-roles/page.tsx +1 -1
  93. package/dist/templates/app/superadmin/teams/[teamId]/page.tsx +1 -1
  94. package/dist/templates/app/superadmin/teams/page.tsx +1 -1
  95. package/dist/templates/app/superadmin/users/[userId]/page.tsx +1 -1
  96. package/dist/templates/app/superadmin/users/page.tsx +1 -1
  97. package/package.json +11 -3
  98. package/scripts/build/registry/__tests__/template-registry.test.mjs +336 -2
  99. package/scripts/build/registry/__tests__/write-places.test.mjs +26 -0
  100. package/scripts/build/registry/generators/template-registry.mjs +202 -9
  101. package/scripts/build/registry/write-places.mjs +19 -5
  102. package/scripts/build/registry.mjs +53 -1
  103. package/templates/app/(auth)/accept-invite/[token]/page.tsx +1 -1
  104. package/templates/app/(auth)/auth-error/page.tsx +1 -1
  105. package/templates/app/(auth)/forgot-password/page.tsx +1 -1
  106. package/templates/app/(auth)/layout.tsx +1 -1
  107. package/templates/app/(auth)/login/page.tsx +1 -1
  108. package/templates/app/(auth)/reset-password/page.tsx +1 -1
  109. package/templates/app/(auth)/signup/page.tsx +1 -1
  110. package/templates/app/(auth)/verify-email/page.tsx +1 -1
  111. package/templates/app/(public)/[...slug]/page.tsx +3 -3
  112. package/templates/app/(public)/layout.tsx +1 -1
  113. package/templates/app/(public)/page.tsx +1 -1
  114. package/templates/app/403/page.tsx +1 -1
  115. package/templates/app/dashboard/(main)/[entity]/[id]/edit/page.tsx +1 -1
  116. package/templates/app/dashboard/(main)/[entity]/[id]/page.tsx +4 -4
  117. package/templates/app/dashboard/(main)/[entity]/create/page.tsx +1 -1
  118. package/templates/app/dashboard/(main)/[entity]/loading.tsx +1 -1
  119. package/templates/app/dashboard/(main)/[entity]/page.tsx +1 -1
  120. package/templates/app/dashboard/(main)/layout.tsx +1 -1
  121. package/templates/app/dashboard/(main)/loading.tsx +1 -1
  122. package/templates/app/dashboard/(main)/media/page.tsx +1 -1
  123. package/templates/app/dashboard/(main)/page.tsx +1 -1
  124. package/templates/app/dashboard/(main)/patterns/[id]/edit/page.tsx +1 -1
  125. package/templates/app/dashboard/(main)/patterns/[id]/page.tsx +1 -1
  126. package/templates/app/dashboard/(main)/patterns/[id]/reports/page.tsx +1 -1
  127. package/templates/app/dashboard/(main)/patterns/create/page.tsx +1 -1
  128. package/templates/app/dashboard/(main)/patterns/page.tsx +1 -1
  129. package/templates/app/dashboard/features/analytics/page.tsx +1 -1
  130. package/templates/app/dashboard/features/automation/page.tsx +1 -1
  131. package/templates/app/dashboard/features/layout.tsx +1 -1
  132. package/templates/app/dashboard/features/loading.tsx +1 -1
  133. package/templates/app/dashboard/features/webhooks/page.tsx +1 -1
  134. package/templates/app/dashboard/layout.tsx +1 -1
  135. package/templates/app/dashboard/permission-denied/page.tsx +1 -1
  136. package/templates/app/dashboard/settings/api-keys/loading.tsx +1 -1
  137. package/templates/app/dashboard/settings/api-keys/page.tsx +1 -1
  138. package/templates/app/dashboard/settings/billing/loading.tsx +1 -1
  139. package/templates/app/dashboard/settings/billing/page.tsx +1 -1
  140. package/templates/app/dashboard/settings/invoices/[invoiceNumber]/page.tsx +1 -1
  141. package/templates/app/dashboard/settings/invoices/loading.tsx +1 -1
  142. package/templates/app/dashboard/settings/invoices/page.tsx +1 -1
  143. package/templates/app/dashboard/settings/layout.tsx +1 -1
  144. package/templates/app/dashboard/settings/loading.tsx +1 -1
  145. package/templates/app/dashboard/settings/notifications/loading.tsx +1 -1
  146. package/templates/app/dashboard/settings/notifications/page.tsx +1 -1
  147. package/templates/app/dashboard/settings/page.tsx +1 -1
  148. package/templates/app/dashboard/settings/password/loading.tsx +1 -1
  149. package/templates/app/dashboard/settings/password/page.tsx +1 -1
  150. package/templates/app/dashboard/settings/plans/loading.tsx +1 -1
  151. package/templates/app/dashboard/settings/plans/page.tsx +1 -1
  152. package/templates/app/dashboard/settings/profile/loading.tsx +1 -1
  153. package/templates/app/dashboard/settings/profile/page.tsx +1 -1
  154. package/templates/app/dashboard/settings/security/loading.tsx +1 -1
  155. package/templates/app/dashboard/settings/security/page.tsx +1 -1
  156. package/templates/app/dashboard/settings/teams/loading.tsx +1 -1
  157. package/templates/app/dashboard/settings/teams/page.tsx +1 -1
  158. package/templates/app/dashboard/settings/teams/permissions/page.tsx +1 -1
  159. package/templates/app/devtools/api/[[...endpoint]]/page.tsx +1 -1
  160. package/templates/app/devtools/config/page.tsx +1 -1
  161. package/templates/app/devtools/layout.tsx +3 -2
  162. package/templates/app/devtools/tests/[[...path]]/page.tsx +1 -1
  163. package/templates/app/layout.ppr.tsx +1 -1
  164. package/templates/app/layout.tsx +1 -1
  165. package/templates/app/public/page.tsx +1 -1
  166. package/templates/app/superadmin/layout.tsx +1 -1
  167. package/templates/app/superadmin/page.tsx +1 -1
  168. package/templates/app/superadmin/subscriptions/page.tsx +1 -1
  169. package/templates/app/superadmin/team-roles/page.tsx +1 -1
  170. package/templates/app/superadmin/teams/[teamId]/page.tsx +1 -1
  171. package/templates/app/superadmin/teams/page.tsx +1 -1
  172. package/templates/app/superadmin/users/[userId]/page.tsx +1 -1
  173. package/templates/app/superadmin/users/page.tsx +1 -1
  174. package/tests/jest/__mocks__/@nextsparkjs/registries/template-scopes/client.ts +21 -0
  175. package/tests/jest/__mocks__/@nextsparkjs/registries/template-scopes/server.ts +23 -0
  176. package/tests/jest/__mocks__/next-intl-server.js +3 -3
@@ -13,13 +13,17 @@
13
13
 
14
14
  import { test } from 'node:test'
15
15
  import assert from 'node:assert/strict'
16
- import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
16
+ import { spawnSync } from 'node:child_process'
17
+ import { existsSync } from 'node:fs'
18
+ import { mkdtemp, mkdir, readdir, readFile, writeFile, rm } from 'node:fs/promises'
17
19
  import { tmpdir } from 'node:os'
18
20
  import { join, dirname } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
19
22
 
20
23
  import {
21
24
  generateTemplateRegistry,
22
- generateTemplateRegistryClient
25
+ generateTemplateRegistryClient,
26
+ generateTemplateScopeRegistries
23
27
  } from '../generators/template-registry.mjs'
24
28
  import { analyzeTemplates } from '../post-build/page-generator.mjs'
25
29
 
@@ -45,6 +49,7 @@ const protectedTemplate = {
45
49
  }
46
50
 
47
51
  const config = { outputDir: '/tmp/registries', projectRoot: '/tmp/project' }
52
+ const CORE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..')
48
53
 
49
54
  // The registry build parses the templates once, with analyzeTemplates, and
50
55
  // hands the same analysis to both registries. These do the same.
@@ -357,3 +362,332 @@ test('a layout with no default export is component: null on the server and has n
357
362
  await rm(root, { recursive: true, force: true })
358
363
  }
359
364
  })
365
+
366
+ test('route scopes isolate template imports, retain empty fallbacks, and preserve the full registries', async () => {
367
+ const root = await mkdtemp(join(tmpdir(), 'nextspark-template-scopes-test-'))
368
+ try {
369
+ const publicPath = await writeThemeTemplate(
370
+ root,
371
+ '(public)/page.tsx',
372
+ 'export default function PublicTemplate() { return null }\n'
373
+ )
374
+ const aiPath = await writeThemeTemplate(
375
+ root,
376
+ 'ai/page.tsx',
377
+ 'export default function AiTemplate() { return null }\n'
378
+ )
379
+ const metadataPath = await writeThemeTemplate(
380
+ root,
381
+ 'layout.meta.ts',
382
+ "export const metadata = { title: 'Theme brand' }\n"
383
+ )
384
+ await writeAppRoute(root, 'app/(public)/page.tsx')
385
+ await writeAppRoute(root, 'app/ai/page.tsx')
386
+ await writeAppRoute(root, 'app/(auth)/empty/page.tsx')
387
+ await writeAppRoute(root, 'app/layout.tsx')
388
+
389
+ const templates = [
390
+ { ...pageTemplate, themeName: 'public', templatePath: publicPath },
391
+ { ...pageTemplate, name: 'ai/page', themeName: 'ai', relativePath: 'ai/page.tsx', appPath: 'app/ai/page.tsx', templatePath: aiPath },
392
+ {
393
+ ...protectedTemplate,
394
+ name: 'root/layout',
395
+ themeName: 'brand',
396
+ relativePath: 'layout.meta.ts',
397
+ appPath: 'app/layout.tsx',
398
+ templatePath: metadataPath,
399
+ metadata: { title: 'Theme brand' }
400
+ }
401
+ ]
402
+ const scopedConfig = { outputDir: join(root, '.nextspark/registries'), projectRoot: root }
403
+ const analysis = await analyzeTemplates(templates, scopedConfig)
404
+ const { files } = await generateTemplateScopeRegistries(templates, scopedConfig, analysis)
405
+ const file = suffix => files.find(candidate => candidate.path.endsWith(suffix))?.content
406
+ const publicServer = file('/template-scopes/server/(public)/page.ts')
407
+ const aiServer = file('/template-scopes/server/ai/page.ts')
408
+ const publicClient = file('/template-scopes/client/(public)/page.ts')
409
+ const emptyServer = file('/template-scopes/server/(auth)/empty/page.ts')
410
+ const protectedServer = file('/template-scopes/server/layout.ts')
411
+
412
+ assert.match(publicServer, /templates\/\(public\)\/page'\)\)/)
413
+ assert.doesNotMatch(publicServer, /templates\/ai\/page/)
414
+ assert.match(aiServer, /templates\/ai\/page'\)\)/)
415
+ assert.doesNotMatch(aiServer, /templates\/\(public\)\/page/)
416
+ assert.match(publicClient, /dynamic\(\(\) => import\('@\/contents\/themes\/testtheme\/templates\/\(public\)\/page'\)\)/)
417
+ assert.doesNotMatch(publicClient, /templates\/ai\/page/)
418
+ assert.match(emptyServer, /TEMPLATE_REGISTRY: Record<string, TemplateRegistryEntry> = \{\s*\}/)
419
+ assert.match(emptyServer, /export function getTemplateOrDefault/)
420
+ assert.doesNotMatch(emptyServer, /TemplateService|template-registry'/)
421
+ assert.match(protectedServer, /component: null/)
422
+ assert.match(protectedServer, /canOverrideMetadata/)
423
+
424
+ const full = await generateTemplateRegistry(templates, scopedConfig, analysis)
425
+ assert.match(full, /app\/\(public\)\/page\.tsx/)
426
+ assert.match(full, /app\/ai\/page\.tsx/)
427
+ const client = await generateTemplateRegistryClient(templates, scopedConfig, analysis)
428
+ assert.doesNotMatch(client, /layout\.tsx/)
429
+ } finally {
430
+ await rm(root, { recursive: true, force: true })
431
+ }
432
+ })
433
+
434
+ // The scoped module is TypeScript, but its resolvers have no runtime
435
+ // dependencies beyond the generated registry and protected-path predicates.
436
+ // Execute exactly that emitted code so semantic parity is not just a snapshot.
437
+ function scopedResolvers(scopeContent, registry, canOverrideComponent = () => true, canOverrideMetadata = () => true) {
438
+ const start = scopeContent.indexOf('export function hasTemplateOverride')
439
+ assert.notEqual(start, -1, 'scope should contain scoped resolvers')
440
+ const emitted = scopeContent
441
+ .slice(start)
442
+ .replace('export function hasTemplateOverride(appPath: string): boolean {', 'function hasTemplateOverride(appPath) {')
443
+ .replace('export function getTemplateComponent(appPath: string): any | null {', 'function getTemplateComponent(appPath) {')
444
+ .replace('export function getTemplateOrDefault<T = any>(appPath: string, defaultComponent: T): T {', 'function getTemplateOrDefault(appPath, defaultComponent) {')
445
+ .replace('component as T', 'component')
446
+ .replace('export function getMetadataOrDefault(appPath: string, defaultMetadata: any): any {', 'function getMetadataOrDefault(appPath, defaultMetadata) {')
447
+ return Function('TEMPLATE_REGISTRY', 'canOverrideComponent', 'canOverrideMetadata', `${emitted}\nreturn { hasTemplateOverride, getTemplateComponent, getTemplateOrDefault, getMetadataOrDefault }`)(
448
+ registry,
449
+ canOverrideComponent,
450
+ canOverrideMetadata
451
+ )
452
+ }
453
+
454
+ test('scoped metadata runtime preserves legacy truthy fallback semantics', async () => {
455
+ const root = await mkdtemp(join(tmpdir(), 'nextspark-template-scopes-test-'))
456
+ try {
457
+ await writeAppRoute(root, 'app/metadata.ts')
458
+ const scopedConfig = { outputDir: join(root, '.nextspark/registries'), projectRoot: root }
459
+ const { files } = await generateTemplateScopeRegistries([], scopedConfig)
460
+ const scope = files.find(file => file.path.endsWith('/template-scopes/server/metadata.ts')).content
461
+ const fallback = { title: 'application default' }
462
+
463
+ for (const metadata of [false, 0, '', null, undefined]) {
464
+ const { getMetadataOrDefault } = scopedResolvers(scope, { 'app/metadata.ts': { template: { metadata } } })
465
+ assert.equal(getMetadataOrDefault('app/metadata.ts', fallback), fallback)
466
+ }
467
+ const override = { title: 'theme override' }
468
+ const { getMetadataOrDefault } = scopedResolvers(scope, { 'app/metadata.ts': { template: { metadata: override } } })
469
+ assert.equal(getMetadataOrDefault('app/metadata.ts', fallback), override)
470
+ } finally {
471
+ await rm(root, { recursive: true, force: true })
472
+ }
473
+ })
474
+
475
+ test('scoped dynamic lookups preserve TemplateService has/get fallback semantics', async () => {
476
+ const root = await mkdtemp(join(tmpdir(), 'nextspark-template-scopes-test-'))
477
+ try {
478
+ await writeAppRoute(root, 'app/(public)/[...slug]/page.tsx')
479
+ const scopedConfig = { outputDir: join(root, '.nextspark/registries'), projectRoot: root }
480
+ const { files } = await generateTemplateScopeRegistries([], scopedConfig)
481
+ const scope = files.find(file => file.path.endsWith('/template-scopes/server/(public)/[...slug]/page.ts')).content
482
+ const component = () => 'template'
483
+ const { hasTemplateOverride, getTemplateComponent } = scopedResolvers(scope, {
484
+ 'app/(public)/blog/[slug]/page.tsx': { component },
485
+ 'app/(public)/metadata-only/page.tsx': { component: null }
486
+ })
487
+
488
+ assert.equal(hasTemplateOverride('app/(public)/blog/[slug]/page.tsx'), true)
489
+ assert.equal(hasTemplateOverride('app/(public)/missing/page.tsx'), false)
490
+ assert.equal(getTemplateComponent('app/(public)/blog/[slug]/page.tsx'), component)
491
+ assert.equal(getTemplateComponent('app/(public)/metadata-only/page.tsx'), null)
492
+ assert.equal(getTemplateComponent('app/(public)/missing/page.tsx'), null)
493
+ } finally {
494
+ await rm(root, { recursive: true, force: true })
495
+ }
496
+ })
497
+
498
+ test('scopes use separate server/client trees and reject extension output ambiguity before writes', async () => {
499
+ const root = await mkdtemp(join(tmpdir(), 'nextspark-template-scopes-test-'))
500
+ try {
501
+ await writeAppRoute(root, 'app/foo.ts')
502
+ await writeAppRoute(root, 'app/foo.client.ts')
503
+ const scopedConfig = { outputDir: join(root, '.nextspark/registries'), projectRoot: root }
504
+ const { files } = await generateTemplateScopeRegistries([], scopedConfig)
505
+ const paths = new Set(files.map(file => file.path))
506
+ for (const path of [
507
+ '/template-scopes/server/foo.ts',
508
+ '/template-scopes/client/foo.ts',
509
+ '/template-scopes/server/foo.client.ts',
510
+ '/template-scopes/client/foo.client.ts'
511
+ ]) assert.ok([...paths].some(candidate => candidate.endsWith(path)), `missing ${path}`)
512
+
513
+ await writeAppRoute(root, 'app/ambiguous.ts')
514
+ await writeAppRoute(root, 'app/ambiguous.tsx')
515
+ await assert.rejects(
516
+ () => generateTemplateScopeRegistries([], scopedConfig),
517
+ /Ambiguous template scope output .*ambiguous\.ts.*app\/ambiguous\.ts.*app\/ambiguous\.tsx/s
518
+ )
519
+ } finally {
520
+ await rm(root, { recursive: true, force: true })
521
+ }
522
+ })
523
+
524
+ test('client scopes exclude side-effect and multiline server-only imports with either quote style', async () => {
525
+ const root = await mkdtemp(join(tmpdir(), 'nextspark-template-registry-test-'))
526
+ try {
527
+ const sideEffectPath = await writeThemeTemplate(root, 'server-only/page.tsx', "import 'server-only'\nexport default function Page() { return null }\n")
528
+ const multilinePath = await writeThemeTemplate(root, 'quoted/layout.tsx', 'import {\n marker\n} from "server-only"\nexport default function Layout() { return null }\n')
529
+ const templates = [
530
+ { ...pageTemplate, appPath: 'app/server-only/page.tsx', relativePath: 'server-only/page.tsx', templatePath: sideEffectPath },
531
+ { ...pageTemplate, appPath: 'app/quoted/layout.tsx', templateType: 'layout', relativePath: 'quoted/layout.tsx', templatePath: multilinePath }
532
+ ]
533
+ const out = await generateClientRegistry(templates, { outputDir: join(root, '.nextspark/registries'), projectRoot: root })
534
+ assert.doesNotMatch(out, /app\/server-only\/page\.tsx/)
535
+ assert.doesNotMatch(out, /app\/quoted\/layout\.tsx/)
536
+ } finally {
537
+ await rm(root, { recursive: true, force: true })
538
+ }
539
+ })
540
+
541
+ test('scoped resolver keeps highest template selection while empty and protected scopes fall back', async () => {
542
+ const root = await mkdtemp(join(tmpdir(), 'nextspark-template-scopes-test-'))
543
+ try {
544
+ const lowPath = await writeThemeTemplate(root, 'priority/low.tsx', 'export default function Low() { return null }\n')
545
+ const highPath = await writeThemeTemplate(root, 'priority/high.tsx', 'export default function High() { return null }\n')
546
+ await writeAppRoute(root, 'app/priority/page.tsx')
547
+ await writeAppRoute(root, 'app/empty/page.tsx')
548
+ const scopedConfig = { outputDir: join(root, '.nextspark/registries'), projectRoot: root }
549
+ const templates = [
550
+ { ...pageTemplate, appPath: 'app/priority/page.tsx', templatePath: lowPath, priority: 1 },
551
+ { ...pageTemplate, appPath: 'app/priority/page.tsx', templatePath: highPath, priority: 10 }
552
+ ]
553
+ const { files } = await generateTemplateScopeRegistries(templates, scopedConfig, await analyzeTemplates(templates, scopedConfig))
554
+ const priorityScope = files.find(file => file.path.endsWith('/template-scopes/server/priority/page.ts')).content
555
+ const emptyScope = files.find(file => file.path.endsWith('/template-scopes/server/empty/page.ts')).content
556
+ assert.ok(priorityScope.indexOf(highPath) < priorityScope.indexOf(lowPath), 'highest-priority template is the selected entry')
557
+
558
+ const fallback = () => 'fallback'
559
+ const override = () => 'override'
560
+ assert.equal(scopedResolvers(emptyScope, {}).getTemplateOrDefault('app/empty/page.tsx', fallback), fallback)
561
+ assert.equal(scopedResolvers(priorityScope, { 'app/priority/page.tsx': { component: override } }).getTemplateOrDefault('app/priority/page.tsx', fallback), override)
562
+ assert.equal(scopedResolvers(priorityScope, { 'app/priority/page.tsx': { component: override } }, () => false).getTemplateOrDefault('app/priority/page.tsx', fallback), fallback)
563
+ } finally {
564
+ await rm(root, { recursive: true, force: true })
565
+ }
566
+ })
567
+
568
+ test('dynamic route scopes contain only their runtime target family, not the full registry', async () => {
569
+ const root = await mkdtemp(join(tmpdir(), 'nextspark-template-scopes-test-'))
570
+ try {
571
+ await writeAppRoute(root, 'app/(public)/[...slug]/page.tsx')
572
+ await writeAppRoute(root, 'app/dashboard/(main)/[entity]/[id]/page.tsx')
573
+
574
+ const publicDynamic = await writeThemeTemplate(root, '(public)/blog/[slug]/page.tsx', 'export default function Blog() { return null }\n')
575
+ const dashboardDetail = await writeThemeTemplate(root, 'dashboard/(main)/projects/[id]/page.tsx', 'export default function Project() { return null }\n')
576
+ const nestedDashboardDetail = await writeThemeTemplate(root, 'dashboard/(main)/boards/[id]/[cardId]/page.tsx', 'export default function Card() { return null }\n')
577
+ const dashboardAi = await writeThemeTemplate(root, 'dashboard/(main)/agent-multi/page.tsx', 'export default function Agent() { return null }\n')
578
+ const observability = await writeThemeTemplate(root, 'superadmin/ai-observability/page.tsx', 'export default function Observability() { return null }\n')
579
+ const templates = [
580
+ { ...pageTemplate, appPath: 'app/(public)/blog/[slug]/page.tsx', relativePath: '(public)/blog/[slug]/page.tsx', templatePath: publicDynamic },
581
+ { ...pageTemplate, appPath: 'app/dashboard/(main)/projects/[id]/page.tsx', relativePath: 'dashboard/(main)/projects/[id]/page.tsx', templatePath: dashboardDetail },
582
+ { ...pageTemplate, appPath: 'app/dashboard/(main)/boards/[id]/[cardId]/page.tsx', relativePath: 'dashboard/(main)/boards/[id]/[cardId]/page.tsx', templatePath: nestedDashboardDetail },
583
+ { ...pageTemplate, appPath: 'app/dashboard/(main)/agent-multi/page.tsx', relativePath: 'dashboard/(main)/agent-multi/page.tsx', templatePath: dashboardAi },
584
+ { ...pageTemplate, appPath: 'app/superadmin/ai-observability/page.tsx', relativePath: 'superadmin/ai-observability/page.tsx', templatePath: observability }
585
+ ]
586
+ const scopedConfig = { outputDir: join(root, '.nextspark/registries'), projectRoot: root }
587
+ const { files } = await generateTemplateScopeRegistries(templates, scopedConfig, await analyzeTemplates(templates, scopedConfig))
588
+ const file = suffix => files.find(candidate => candidate.path.endsWith(suffix))?.content
589
+ const publicServer = file('/template-scopes/server/(public)/[...slug]/page.ts')
590
+ const publicClient = file('/template-scopes/client/(public)/[...slug]/page.ts')
591
+ const dashboardServer = file('/template-scopes/server/dashboard/(main)/[entity]/[id]/page.ts')
592
+
593
+ assert.match(publicServer, /templates\/\(public\)\/blog\/\[slug\]\/page'\)\)/)
594
+ assert.match(publicServer, /export function hasTemplateOverride/)
595
+ assert.match(publicServer, /export function getTemplateComponent/)
596
+ assert.doesNotMatch(publicServer, /agent-multi|ai-observability|projects\/\[id\]/)
597
+ assert.match(publicClient, /templates\/\(public\)\/blog\/\[slug\]\/page'\)\)/)
598
+ assert.doesNotMatch(publicClient, /agent-multi|ai-observability|projects\/\[id\]/)
599
+
600
+ assert.match(dashboardServer, /templates\/dashboard\/\(main\)\/projects\/\[id\]\/page'\)\)/)
601
+ assert.doesNotMatch(dashboardServer, /\(public\)\/blog|boards\/\[id\]\/\[cardId\]|agent-multi|ai-observability/)
602
+ } finally {
603
+ await rm(root, { recursive: true, force: true })
604
+ }
605
+ })
606
+
607
+ async function scopedFiles(root) {
608
+ const directory = join(root, '.nextspark/registries/template-scopes')
609
+ const files = []
610
+ async function visit(path, relative = '') {
611
+ for (const entry of await readdir(path, { withFileTypes: true })) {
612
+ const entryPath = join(path, entry.name)
613
+ const entryRelative = relative ? `${relative}/${entry.name}` : entry.name
614
+ if (entry.isDirectory()) await visit(entryPath, entryRelative)
615
+ else if (entry.isFile()) files.push([entryRelative, await readFile(entryPath, 'utf8')])
616
+ }
617
+ }
618
+ await visit(directory)
619
+ return files.sort(([left], [right]) => left.localeCompare(right))
620
+ }
621
+
622
+ function runRegistryBuild(root) {
623
+ const result = spawnSync('node', ['scripts/build/registry.mjs'], {
624
+ cwd: CORE_DIR,
625
+ env: { ...process.env, NEXTSPARK_PROJECT_ROOT: root },
626
+ encoding: 'utf8',
627
+ })
628
+ assert.equal(result.status, 0, `${result.stdout}${result.stderr}`)
629
+ }
630
+
631
+ function withoutBuildTimestamps(content) {
632
+ return content.replace(/(Generated at|generatedAt): .*$/gm, '$1: <build-time>')
633
+ }
634
+
635
+ test('complete builds exclude generated template pages and layout copies from route scopes, then prune both in one build', async () => {
636
+ const root = await mkdtemp(join(tmpdir(), 'nextspark-template-scopes-build-test-'))
637
+ try {
638
+ await writeFile(join(root, '.env'), 'NEXT_PUBLIC_ACTIVE_THEME=acme\n')
639
+ await writeFile(join(root, 'package.json'), '{}\n')
640
+ await writeAppRoute(root, 'app/shop/layout.tsx', 'export default function ShopLayout({ children }) { return children }\n')
641
+ await writeAppRoute(root, 'contents/themes/acme/config/theme.config.ts', 'export const acmeThemeConfig = {}\n')
642
+ const template = join(root, 'contents/themes/acme/templates/shop/page.tsx')
643
+ await mkdir(dirname(template), { recursive: true })
644
+ await writeFile(template, 'export default function ShopPage() { return null }\n')
645
+
646
+ runRegistryBuild(root)
647
+ const firstScopes = await scopedFiles(root)
648
+ assert.equal(existsSync(join(root, 'app/(templates)/shop/page.tsx')), true, 'the generated template page exists')
649
+ const generatedLayout = await readFile(join(root, 'app/(templates)/shop/layout.tsx'), 'utf8')
650
+ assert.match(generatedLayout, /copy of app\/shop\/layout\.tsx/)
651
+
652
+ runRegistryBuild(root)
653
+ const secondScopes = await scopedFiles(root)
654
+ assert.deepEqual(
655
+ secondScopes.map(([path]) => path),
656
+ firstScopes.map(([path]) => path),
657
+ 'a generated page and copied layout do not become scopes on the next build'
658
+ )
659
+ assert.deepEqual(
660
+ secondScopes.map(([path, content]) => [path, withoutBuildTimestamps(content)]),
661
+ firstScopes.map(([path, content]) => [path, withoutBuildTimestamps(content)])
662
+ )
663
+
664
+ await rm(template)
665
+ runRegistryBuild(root)
666
+ const prunedScopes = await scopedFiles(root)
667
+ assert.equal(existsSync(join(root, 'app/(templates)/shop/page.tsx')), false, 'the removed template page is pruned in the same build')
668
+ assert.equal(existsSync(join(root, 'app/(templates)/shop/layout.tsx')), false, 'the generated layout copy is pruned in the same build')
669
+ assert.deepEqual(
670
+ prunedScopes.map(([path]) => path),
671
+ ['client/shop/layout.ts', 'server/shop/layout.ts'],
672
+ 'only the real application layout keeps a scope after the template is removed'
673
+ )
674
+ } finally {
675
+ await rm(root, { recursive: true, force: true })
676
+ }
677
+ })
678
+
679
+ test('server route scopes are marked server-only while client scopes remain client-safe', async () => {
680
+ const root = await mkdtemp(join(tmpdir(), 'nextspark-template-scopes-test-'))
681
+ try {
682
+ await writeAppRoute(root, 'app/shop/page.tsx')
683
+ const scopedConfig = { outputDir: join(root, '.nextspark/registries'), projectRoot: root }
684
+ const { files } = await generateTemplateScopeRegistries([], scopedConfig)
685
+ const serverScope = files.find(file => file.path.endsWith('/template-scopes/server/shop/page.ts')).content
686
+ const clientScope = files.find(file => file.path.endsWith('/template-scopes/client/shop/page.ts')).content
687
+
688
+ assert.match(serverScope, /import 'server-only'/)
689
+ assert.doesNotMatch(clientScope, /server-only/)
690
+ } finally {
691
+ await rm(root, { recursive: true, force: true })
692
+ }
693
+ })
@@ -136,6 +136,32 @@ test('a directory the build lists that cannot be read is found', { skip: RUNS_AS
136
136
  }
137
137
  })
138
138
 
139
+ test('only the generated template-scopes registry subtree may be a directory, and it is still guarded', async () => {
140
+ const { root, cleanup } = await directory()
141
+ const outside = await directory()
142
+ try {
143
+ await mkdir(join(root, 'app'), { recursive: true })
144
+ await writeIn(root, '.nextspark/registries/template-scopes/server/(public)/page.ts', '/** Auto-generated route-scoped template registry; do not edit. */\n')
145
+
146
+ assert.deepEqual(unsafeWritePlaces(root), [])
147
+
148
+ await symlink(join(outside.root, 'scope'), join(root, '.nextspark/registries/template-scopes/server/(public)/link'))
149
+ assert.deepEqual(unsafeWritePlaces(root), [
150
+ { path: '.nextspark/registries/template-scopes/server/(public)/link', problem: 'is a symlink' }
151
+ ])
152
+ await rm(join(root, '.nextspark/registries/template-scopes/server/(public)/link'))
153
+
154
+ await rm(join(root, '.nextspark/registries/template-scopes'), { recursive: true })
155
+ await writeIn(root, '.nextspark/registries/template-scopes', '')
156
+ assert.deepEqual(unsafeWritePlaces(root), [
157
+ { path: '.nextspark/registries/template-scopes', problem: 'is not a directory' }
158
+ ])
159
+ } finally {
160
+ await outside.cleanup()
161
+ await cleanup()
162
+ }
163
+ })
164
+
139
165
  test('a .nextspark/backups/.gitignore counts as in place only as a readable file with * as its one pattern, and anything else is found', async () => {
140
166
  const contents = [
141
167
  ['*\n', 'in place'],
@@ -6,16 +6,15 @@
6
6
  * @module core/scripts/build/registry/generators/template-registry
7
7
  */
8
8
 
9
- import { readFile } from 'fs/promises'
10
- import { join, dirname } from 'path'
9
+ import { lstat, readdir, readFile } from 'fs/promises'
10
+ import { join, dirname, relative, sep } from 'path'
11
11
  import { fileURLToPath } from 'url'
12
12
 
13
13
  import { verbose } from '../../../utils/index.mjs'
14
- import {
15
- canOverrideComponent
16
- } from '../../../../dist/config/protected-paths.js'
14
+ import { canOverrideComponent } from '../../../../dist/config/protected-paths.js'
17
15
  import { convertCorePath } from '../config.mjs'
18
16
  import { analyzeTemplates, routeFileAction, templateAnalysisFor } from '../post-build/page-generator.mjs'
17
+ import { loadTypeScriptFor } from '../shared/typescript-compiler.mjs'
19
18
 
20
19
  const __filename = fileURLToPath(import.meta.url)
21
20
  const __dirname = dirname(__filename)
@@ -204,9 +203,32 @@ export const TEMPLATE_METADATA = {
204
203
  * @param {string} filePath - Path to the template file
205
204
  * @returns {Promise<boolean>} Whether the file has server-only exports
206
205
  */
207
- async function hasServerOnlyExports(filePath) {
206
+ async function hasServerOnlyExports(filePath, projectRoot) {
208
207
  try {
209
208
  const content = await readFile(filePath, 'utf8')
209
+ // Prefer the compiler's import declarations: unlike a source scan they
210
+ // distinguish a real import from a comment/string and cover multiline
211
+ // named imports as well as the usual side-effect `import 'server-only'`.
212
+ try {
213
+ const ts = await loadTypeScriptFor(projectRoot)
214
+ const sourceFile = ts.createSourceFile(
215
+ filePath,
216
+ content,
217
+ ts.ScriptTarget.Latest,
218
+ true,
219
+ ts.getScriptKindFromFileName(filePath)
220
+ )
221
+ for (const statement of sourceFile.statements) {
222
+ if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
223
+ if (statement.moduleSpecifier.text === 'next/headers' || statement.moduleSpecifier.text === 'server-only') {
224
+ return true
225
+ }
226
+ }
227
+ }
228
+ } catch {
229
+ // A project without the compiler API can still use the deliberately
230
+ // anchored fallback below. The other checks have always been scans.
231
+ }
210
232
  // Check for server-only function exports
211
233
  const serverFunctionExports = [
212
234
  /export\s+(async\s+)?function\s+generateMetadata/,
@@ -223,8 +245,8 @@ async function hasServerOnlyExports(filePath) {
223
245
 
224
246
  // Check for server-only module imports
225
247
  const serverOnlyImports = [
226
- /import\s+.*from\s+['"]next\/headers['"]/,
227
- /import\s+.*from\s+['"]server-only['"]/,
248
+ /(?:^|[;\r\n])\s*import\s+(?:type\s+)?(?:(?:[\w*$][^;'"`]*?)\s+from\s+)?(['"])next\/headers\1\s*;?/m,
249
+ /(?:^|[;\r\n])\s*import\s+(?:type\s+)?(?:(?:[\w*$][^;'"`]*?)\s+from\s+)?(['"])server-only\1\s*;?/m,
228
250
  ]
229
251
 
230
252
  // Check if default export is an async function (server component marker in Next.js)
@@ -277,7 +299,7 @@ export async function generateTemplateRegistryClient(templates, config, analysis
277
299
  }
278
300
 
279
301
  // Check for server-only exports
280
- const hasServerExports = await hasServerOnlyExports(actualFilePath)
302
+ const hasServerExports = await hasServerOnlyExports(actualFilePath, projectRoot)
281
303
  if (hasServerExports) {
282
304
  verbose(`Excluding ${appPath} from client registry (has server-only exports)`)
283
305
  return null
@@ -365,3 +387,174 @@ export function withTemplateOverrideForClient<P extends object>(appPath: string)
365
387
  }
366
388
  `
367
389
  }
390
+
391
+ // Route scopes deliberately contain their own, small registry. In particular,
392
+ // do not import TemplateService or the full template registry here: a route
393
+ // importing a scope must not reconnect its bundle to every theme template.
394
+ const SCOPE_MARKER = 'Auto-generated route-scoped template registry'
395
+ const TYPE_SCRIPT_ROUTE = /\.(?:tsx|ts)$/
396
+
397
+ /** Turn an app route filename into its matching safe server/client scope filename. */
398
+ function scopeFileFor(appPath, scopeDirectory, client = false) {
399
+ if (typeof appPath !== 'string' || !appPath.startsWith('app/') || !TYPE_SCRIPT_ROUTE.test(appPath)) {
400
+ throw new Error(`Invalid template scope app path: ${String(appPath)}`)
401
+ }
402
+ const route = appPath.slice('app/'.length).replace(TYPE_SCRIPT_ROUTE, '')
403
+ const segments = route.split('/')
404
+ if (!route || segments.some(segment => !segment || segment === '.' || segment === '..' || segment.includes('\\'))) {
405
+ throw new Error(`Invalid template scope app path: ${appPath}`)
406
+ }
407
+ return join(scopeDirectory, client ? 'client' : 'server', `${route}.ts`)
408
+ }
409
+
410
+ /** Read actual route modules without following symlinks or descending into node_modules. */
411
+ async function discoverAppRoutePaths(projectRoot) {
412
+ const appDirectory = join(projectRoot, 'app')
413
+ const routePaths = []
414
+ async function visit(directory) {
415
+ let entries
416
+ try {
417
+ entries = await readdir(directory, { withFileTypes: true })
418
+ } catch (error) {
419
+ if (error?.code === 'ENOENT') return
420
+ throw error
421
+ }
422
+ for (const entry of entries) {
423
+ if (entry.name === 'node_modules') continue
424
+ // The registry build writes generated template routes and layout copies
425
+ // here after scopes are planned. They are implementation artifacts, not
426
+ // application routes, so never let a later build turn them into scopes.
427
+ if (directory === appDirectory && entry.name === '(templates)') continue
428
+ const absolute = join(directory, entry.name)
429
+ // Dirent is a hint only; lstat prevents traversing a changed symlink.
430
+ const stat = await lstat(absolute)
431
+ if (stat.isSymbolicLink()) continue
432
+ if (stat.isDirectory()) await visit(absolute)
433
+ else if (stat.isFile() && TYPE_SCRIPT_ROUTE.test(entry.name) && !entry.name.endsWith('.d.ts')) {
434
+ routePaths.push(`app/${relative(appDirectory, absolute).split(sep).join('/')}`)
435
+ }
436
+ }
437
+ }
438
+ await visit(appDirectory)
439
+ return routePaths
440
+ }
441
+
442
+ function scopedServerResolver(outputFilePath, config) {
443
+ const protectedPaths = convertCorePath('@/core/config/protected-paths', outputFilePath, config)
444
+ return `
445
+
446
+ import { canOverrideComponent, canOverrideMetadata } from '${protectedPaths}'
447
+
448
+ /**
449
+ * The two dynamic application routes need to ask about a runtime-generated
450
+ * app path. Keep this small compatibility surface local to their bounded
451
+ * scope instead of importing the global service, whose registry has every
452
+ * template in the application.
453
+ */
454
+ export function hasTemplateOverride(appPath: string): boolean {
455
+ return appPath in TEMPLATE_REGISTRY
456
+ }
457
+
458
+ /** Return the selected component using the global service's get-component semantics. */
459
+ export function getTemplateComponent(appPath: string): any | null {
460
+ return TEMPLATE_REGISTRY[appPath]?.component || null
461
+ }
462
+
463
+ /** Resolve only this route's selected template; no global registry is imported. */
464
+ export function getTemplateOrDefault<T = any>(appPath: string, defaultComponent: T): T {
465
+ if (!canOverrideComponent(appPath)) return defaultComponent
466
+ const component = TEMPLATE_REGISTRY[appPath]?.component
467
+ return component ? component as T : defaultComponent
468
+ }
469
+
470
+ /** Resolve metadata from only this route's selected template. */
471
+ export function getMetadataOrDefault(appPath: string, defaultMetadata: any): any {
472
+ if (!canOverrideMetadata(appPath)) return defaultMetadata
473
+ const metadata = TEMPLATE_REGISTRY[appPath]?.template?.metadata
474
+ return metadata ? metadata : defaultMetadata
475
+ }
476
+ `
477
+ }
478
+
479
+ const PUBLIC_DYNAMIC_CATCH_ALL_PATH = 'app/(public)/[...slug]/page.tsx'
480
+ const DASHBOARD_DYNAMIC_DETAIL_PATH = 'app/dashboard/(main)/[entity]/[id]/page.tsx'
481
+
482
+ /**
483
+ * The dynamic routes construct their target template path from an entity at
484
+ * runtime. Their scope therefore needs the possible templates for *that
485
+ * route family*, rather than an exact path that cannot be known at build time.
486
+ *
487
+ * These predicates intentionally describe the strings built in the two route
488
+ * modules. Do not widen them to an area prefix: doing so would recreate the
489
+ * full-registry fan-out this generator exists to avoid.
490
+ */
491
+ function isPublicDynamicEntityTemplate(appPath) {
492
+ return /^app\/\(public\)\/(?:.+\/)?\[(?:slug|entity)\]\/page\.(?:tsx|ts)$/.test(appPath)
493
+ }
494
+
495
+ function isDashboardDynamicDetailTemplate(appPath) {
496
+ return /^app\/dashboard\/\(main\)\/[^/]+\/\[id\]\/page\.(?:tsx|ts)$/.test(appPath)
497
+ }
498
+
499
+ function templatesForScope(templates, scopeAppPath) {
500
+ return templates.filter(template => {
501
+ if (template.appPath === scopeAppPath) return true
502
+ if (scopeAppPath === PUBLIC_DYNAMIC_CATCH_ALL_PATH) {
503
+ return isPublicDynamicEntityTemplate(template.appPath)
504
+ }
505
+ if (scopeAppPath === DASHBOARD_DYNAMIC_DETAIL_PATH) {
506
+ return isDashboardDynamicDetailTemplate(template.appPath)
507
+ }
508
+ return false
509
+ })
510
+ }
511
+
512
+ /**
513
+ * Fail while constructing the complete write plan if two app route extensions
514
+ * normalize to the same scope module. This happens before registry.mjs can
515
+ * delete stale files or write any replacement scopes.
516
+ */
517
+ function assertUniqueScopeOutputs(appPaths, scopeDirectory) {
518
+ const outputs = new Map()
519
+ for (const appPath of appPaths) {
520
+ for (const client of [false, true]) {
521
+ const outputPath = scopeFileFor(appPath, scopeDirectory, client)
522
+ const previous = outputs.get(outputPath)
523
+ if (previous && previous !== appPath) {
524
+ throw new Error(
525
+ `Ambiguous template scope output ${outputPath}: both ${previous} and ${appPath} normalize to this file. ` +
526
+ 'Keep only one route extension (.ts or .tsx) for the route before generating registries.'
527
+ )
528
+ }
529
+ outputs.set(outputPath, appPath)
530
+ }
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Generate route-scoped server/client modules as paths and content. The build
536
+ * writes this plan via safe-fs; empty scopes preserve a route fallback when a
537
+ * theme change removes its override.
538
+ */
539
+ export async function generateTemplateScopeRegistries(templates, config, analysis = null) {
540
+ const scopeDirectory = join(config.outputDir, 'template-scopes')
541
+ const paths = new Set(await discoverAppRoutePaths(config.projectRoot))
542
+ for (const template of templates) paths.add(template.appPath)
543
+ assertUniqueScopeOutputs(paths, scopeDirectory)
544
+ const files = []
545
+ for (const appPath of [...paths].sort()) {
546
+ const selected = templatesForScope(templates, appPath)
547
+ const serverPath = scopeFileFor(appPath, scopeDirectory)
548
+ const clientPath = scopeFileFor(appPath, scopeDirectory, true)
549
+ const serverRegistry = await generateTemplateRegistry(selected, config, analysis)
550
+ const clientRegistry = await generateTemplateRegistryClient(selected, config, analysis)
551
+ files.push(
552
+ { path: serverPath, content: `/** ${SCOPE_MARKER}; do not edit. */\nimport 'server-only'\n${serverRegistry}${scopedServerResolver(serverPath, config)}` },
553
+ { path: clientPath, content: `/** ${SCOPE_MARKER}; do not edit. */\n${clientRegistry}` }
554
+ )
555
+ }
556
+ return { directory: scopeDirectory, files }
557
+ }
558
+
559
+ /** Marker used to restrict stale cleanup to files this generator owns. */
560
+ export { SCOPE_MARKER }