@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.
- package/dist/components/dashboard/block-editor/dynamic-form.d.ts.map +1 -1
- package/dist/components/dashboard/block-editor/dynamic-form.js +2 -2
- package/dist/components/dashboard/layouts/TopNavbar.js +1 -1
- package/dist/components/dashboard/mobile/MobileTopBar.js +3 -2
- package/dist/emails/otp-verification.d.ts.map +1 -1
- package/dist/emails/otp-verification.js +1 -1
- package/dist/emails/reset-password.d.ts.map +1 -1
- package/dist/emails/reset-password.js +1 -1
- package/dist/emails/team-invitation.d.ts.map +1 -1
- package/dist/emails/team-invitation.js +1 -1
- package/dist/emails/verify-email.d.ts.map +1 -1
- package/dist/emails/verify-email.js +1 -1
- package/dist/lib/actions/entity.actions.js +8 -8
- package/dist/lib/actions/team.actions.js +7 -7
- package/dist/lib/api/entity/generic-handler.js +5 -5
- package/dist/lib/api/permission-middleware.js +1 -1
- package/dist/lib/auth/registration-guard-plugin.d.ts.map +1 -1
- package/dist/lib/auth/registration-guard-plugin.js +2 -2
- package/dist/lib/auth-email-callbacks.d.ts +3 -1
- package/dist/lib/auth-email-callbacks.d.ts.map +1 -1
- package/dist/lib/auth-email-callbacks.js +8 -2
- package/dist/lib/auth.d.ts.map +1 -1
- package/dist/lib/auth.js +4 -3
- package/dist/lib/billing/gateways/stripe.js +1 -1
- package/dist/lib/entities/hooks.js +1 -1
- package/dist/templates/app/(auth)/accept-invite/[token]/page.tsx +1 -1
- package/dist/templates/app/(auth)/auth-error/page.tsx +1 -1
- package/dist/templates/app/(auth)/forgot-password/page.tsx +1 -1
- package/dist/templates/app/(auth)/layout.tsx +1 -1
- package/dist/templates/app/(auth)/login/page.tsx +1 -1
- package/dist/templates/app/(auth)/reset-password/page.tsx +1 -1
- package/dist/templates/app/(auth)/signup/page.tsx +1 -1
- package/dist/templates/app/(auth)/verify-email/page.tsx +1 -1
- package/dist/templates/app/(public)/[...slug]/page.tsx +3 -3
- package/dist/templates/app/(public)/layout.tsx +1 -1
- package/dist/templates/app/(public)/page.tsx +1 -1
- package/dist/templates/app/403/page.tsx +1 -1
- package/dist/templates/app/dashboard/(main)/[entity]/[id]/edit/page.tsx +1 -1
- package/dist/templates/app/dashboard/(main)/[entity]/[id]/page.tsx +4 -4
- package/dist/templates/app/dashboard/(main)/[entity]/create/page.tsx +1 -1
- package/dist/templates/app/dashboard/(main)/[entity]/loading.tsx +1 -1
- package/dist/templates/app/dashboard/(main)/[entity]/page.tsx +1 -1
- package/dist/templates/app/dashboard/(main)/layout.tsx +1 -1
- package/dist/templates/app/dashboard/(main)/loading.tsx +1 -1
- package/dist/templates/app/dashboard/(main)/media/page.tsx +1 -1
- package/dist/templates/app/dashboard/(main)/page.tsx +1 -1
- package/dist/templates/app/dashboard/(main)/patterns/[id]/edit/page.tsx +1 -1
- package/dist/templates/app/dashboard/(main)/patterns/[id]/page.tsx +1 -1
- package/dist/templates/app/dashboard/(main)/patterns/[id]/reports/page.tsx +1 -1
- package/dist/templates/app/dashboard/(main)/patterns/create/page.tsx +1 -1
- package/dist/templates/app/dashboard/(main)/patterns/page.tsx +1 -1
- package/dist/templates/app/dashboard/features/analytics/page.tsx +1 -1
- package/dist/templates/app/dashboard/features/automation/page.tsx +1 -1
- package/dist/templates/app/dashboard/features/layout.tsx +1 -1
- package/dist/templates/app/dashboard/features/loading.tsx +1 -1
- package/dist/templates/app/dashboard/features/webhooks/page.tsx +1 -1
- package/dist/templates/app/dashboard/layout.tsx +1 -1
- package/dist/templates/app/dashboard/permission-denied/page.tsx +1 -1
- package/dist/templates/app/dashboard/settings/api-keys/loading.tsx +1 -1
- package/dist/templates/app/dashboard/settings/api-keys/page.tsx +1 -1
- package/dist/templates/app/dashboard/settings/billing/loading.tsx +1 -1
- package/dist/templates/app/dashboard/settings/billing/page.tsx +1 -1
- package/dist/templates/app/dashboard/settings/invoices/[invoiceNumber]/page.tsx +1 -1
- package/dist/templates/app/dashboard/settings/invoices/loading.tsx +1 -1
- package/dist/templates/app/dashboard/settings/invoices/page.tsx +1 -1
- package/dist/templates/app/dashboard/settings/layout.tsx +1 -1
- package/dist/templates/app/dashboard/settings/loading.tsx +1 -1
- package/dist/templates/app/dashboard/settings/notifications/loading.tsx +1 -1
- package/dist/templates/app/dashboard/settings/notifications/page.tsx +1 -1
- package/dist/templates/app/dashboard/settings/page.tsx +1 -1
- package/dist/templates/app/dashboard/settings/password/loading.tsx +1 -1
- package/dist/templates/app/dashboard/settings/password/page.tsx +1 -1
- package/dist/templates/app/dashboard/settings/plans/loading.tsx +1 -1
- package/dist/templates/app/dashboard/settings/plans/page.tsx +1 -1
- package/dist/templates/app/dashboard/settings/profile/loading.tsx +1 -1
- package/dist/templates/app/dashboard/settings/profile/page.tsx +1 -1
- package/dist/templates/app/dashboard/settings/security/loading.tsx +1 -1
- package/dist/templates/app/dashboard/settings/security/page.tsx +1 -1
- package/dist/templates/app/dashboard/settings/teams/loading.tsx +1 -1
- package/dist/templates/app/dashboard/settings/teams/page.tsx +1 -1
- package/dist/templates/app/dashboard/settings/teams/permissions/page.tsx +1 -1
- package/dist/templates/app/devtools/api/[[...endpoint]]/page.tsx +1 -1
- package/dist/templates/app/devtools/config/page.tsx +1 -1
- package/dist/templates/app/devtools/layout.tsx +3 -2
- package/dist/templates/app/devtools/tests/[[...path]]/page.tsx +1 -1
- package/dist/templates/app/layout.ppr.tsx +1 -1
- package/dist/templates/app/layout.tsx +1 -1
- package/dist/templates/app/public/page.tsx +1 -1
- package/dist/templates/app/superadmin/layout.tsx +1 -1
- package/dist/templates/app/superadmin/page.tsx +1 -1
- package/dist/templates/app/superadmin/subscriptions/page.tsx +1 -1
- package/dist/templates/app/superadmin/team-roles/page.tsx +1 -1
- package/dist/templates/app/superadmin/teams/[teamId]/page.tsx +1 -1
- package/dist/templates/app/superadmin/teams/page.tsx +1 -1
- package/dist/templates/app/superadmin/users/[userId]/page.tsx +1 -1
- package/dist/templates/app/superadmin/users/page.tsx +1 -1
- package/package.json +11 -3
- package/scripts/build/registry/__tests__/template-registry.test.mjs +336 -2
- package/scripts/build/registry/__tests__/write-places.test.mjs +26 -0
- package/scripts/build/registry/generators/template-registry.mjs +202 -9
- package/scripts/build/registry/write-places.mjs +19 -5
- package/scripts/build/registry.mjs +53 -1
- package/templates/app/(auth)/accept-invite/[token]/page.tsx +1 -1
- package/templates/app/(auth)/auth-error/page.tsx +1 -1
- package/templates/app/(auth)/forgot-password/page.tsx +1 -1
- package/templates/app/(auth)/layout.tsx +1 -1
- package/templates/app/(auth)/login/page.tsx +1 -1
- package/templates/app/(auth)/reset-password/page.tsx +1 -1
- package/templates/app/(auth)/signup/page.tsx +1 -1
- package/templates/app/(auth)/verify-email/page.tsx +1 -1
- package/templates/app/(public)/[...slug]/page.tsx +3 -3
- package/templates/app/(public)/layout.tsx +1 -1
- package/templates/app/(public)/page.tsx +1 -1
- package/templates/app/403/page.tsx +1 -1
- package/templates/app/dashboard/(main)/[entity]/[id]/edit/page.tsx +1 -1
- package/templates/app/dashboard/(main)/[entity]/[id]/page.tsx +4 -4
- package/templates/app/dashboard/(main)/[entity]/create/page.tsx +1 -1
- package/templates/app/dashboard/(main)/[entity]/loading.tsx +1 -1
- package/templates/app/dashboard/(main)/[entity]/page.tsx +1 -1
- package/templates/app/dashboard/(main)/layout.tsx +1 -1
- package/templates/app/dashboard/(main)/loading.tsx +1 -1
- package/templates/app/dashboard/(main)/media/page.tsx +1 -1
- package/templates/app/dashboard/(main)/page.tsx +1 -1
- package/templates/app/dashboard/(main)/patterns/[id]/edit/page.tsx +1 -1
- package/templates/app/dashboard/(main)/patterns/[id]/page.tsx +1 -1
- package/templates/app/dashboard/(main)/patterns/[id]/reports/page.tsx +1 -1
- package/templates/app/dashboard/(main)/patterns/create/page.tsx +1 -1
- package/templates/app/dashboard/(main)/patterns/page.tsx +1 -1
- package/templates/app/dashboard/features/analytics/page.tsx +1 -1
- package/templates/app/dashboard/features/automation/page.tsx +1 -1
- package/templates/app/dashboard/features/layout.tsx +1 -1
- package/templates/app/dashboard/features/loading.tsx +1 -1
- package/templates/app/dashboard/features/webhooks/page.tsx +1 -1
- package/templates/app/dashboard/layout.tsx +1 -1
- package/templates/app/dashboard/permission-denied/page.tsx +1 -1
- package/templates/app/dashboard/settings/api-keys/loading.tsx +1 -1
- package/templates/app/dashboard/settings/api-keys/page.tsx +1 -1
- package/templates/app/dashboard/settings/billing/loading.tsx +1 -1
- package/templates/app/dashboard/settings/billing/page.tsx +1 -1
- package/templates/app/dashboard/settings/invoices/[invoiceNumber]/page.tsx +1 -1
- package/templates/app/dashboard/settings/invoices/loading.tsx +1 -1
- package/templates/app/dashboard/settings/invoices/page.tsx +1 -1
- package/templates/app/dashboard/settings/layout.tsx +1 -1
- package/templates/app/dashboard/settings/loading.tsx +1 -1
- package/templates/app/dashboard/settings/notifications/loading.tsx +1 -1
- package/templates/app/dashboard/settings/notifications/page.tsx +1 -1
- package/templates/app/dashboard/settings/page.tsx +1 -1
- package/templates/app/dashboard/settings/password/loading.tsx +1 -1
- package/templates/app/dashboard/settings/password/page.tsx +1 -1
- package/templates/app/dashboard/settings/plans/loading.tsx +1 -1
- package/templates/app/dashboard/settings/plans/page.tsx +1 -1
- package/templates/app/dashboard/settings/profile/loading.tsx +1 -1
- package/templates/app/dashboard/settings/profile/page.tsx +1 -1
- package/templates/app/dashboard/settings/security/loading.tsx +1 -1
- package/templates/app/dashboard/settings/security/page.tsx +1 -1
- package/templates/app/dashboard/settings/teams/loading.tsx +1 -1
- package/templates/app/dashboard/settings/teams/page.tsx +1 -1
- package/templates/app/dashboard/settings/teams/permissions/page.tsx +1 -1
- package/templates/app/devtools/api/[[...endpoint]]/page.tsx +1 -1
- package/templates/app/devtools/config/page.tsx +1 -1
- package/templates/app/devtools/layout.tsx +3 -2
- package/templates/app/devtools/tests/[[...path]]/page.tsx +1 -1
- package/templates/app/layout.ppr.tsx +1 -1
- package/templates/app/layout.tsx +1 -1
- package/templates/app/public/page.tsx +1 -1
- package/templates/app/superadmin/layout.tsx +1 -1
- package/templates/app/superadmin/page.tsx +1 -1
- package/templates/app/superadmin/subscriptions/page.tsx +1 -1
- package/templates/app/superadmin/team-roles/page.tsx +1 -1
- package/templates/app/superadmin/teams/[teamId]/page.tsx +1 -1
- package/templates/app/superadmin/teams/page.tsx +1 -1
- package/templates/app/superadmin/users/[userId]/page.tsx +1 -1
- package/templates/app/superadmin/users/page.tsx +1 -1
- package/tests/jest/__mocks__/@nextsparkjs/registries/template-scopes/client.ts +21 -0
- package/tests/jest/__mocks__/@nextsparkjs/registries/template-scopes/server.ts +23 -0
- 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 {
|
|
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
|
|
227
|
-
/import\s
|
|
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 }
|