@wular/pnext 0.1.4 → 0.1.5

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 (44) hide show
  1. package/config/ts/react.json +2 -1
  2. package/package.json +1 -1
  3. package/reference/navigation.md +3 -1
  4. package/src/api/client-navigation.ts +53 -4
  5. package/src/api/link.tsx +6 -7
  6. package/src/cli/adapters/vercel-warm.ts +6 -1
  7. package/src/cli/adapters/vercel.ts +1 -1
  8. package/src/cli/build/cache.ts +8 -3
  9. package/src/cli/build.ts +3 -2
  10. package/src/cli/serve/pipeline.ts +11 -3
  11. package/src/cli/typegen.ts +65 -3
  12. package/src/client/build.ts +10 -7
  13. package/src/client/entry.ts +50 -29
  14. package/src/client/reference-stub.ts +1 -1
  15. package/src/client/router/hub.ts +13 -11
  16. package/src/client/router/page-slot.ts +58 -0
  17. package/src/client/router/runtime.ts +377 -388
  18. package/src/compat/bundler/symlink-imports.ts +1 -1
  19. package/src/compat/client/errors/bare-boundary.ts +1 -1
  20. package/src/compat/client/errors/error-boundary.ts +1 -1
  21. package/src/compat/client/errors/global-error.ts +40 -79
  22. package/src/compat/client/errors/install.ts +1 -1
  23. package/src/compat/client/errors/lazy.ts +7 -0
  24. package/src/compat/client/errors/primitive-throw.ts +36 -53
  25. package/src/compat/client/segment-cache-policy.ts +5 -22
  26. package/src/compat/client/segment-cache.ts +7 -26
  27. package/src/compat/css/modules.ts +1 -7
  28. package/src/compat/css/sass.ts +1 -7
  29. package/src/compat/react/parity.ts +1 -1
  30. package/src/compat/register/bundler.ts +9 -1
  31. package/src/compat/tsconfig-defaults.ts +4 -7
  32. package/src/compat-bootstrap.ts +15 -0
  33. package/src/css/build.ts +18 -15
  34. package/src/dev/server.ts +2 -2
  35. package/src/index.ts +2 -0
  36. package/src/render/renderer.ts +209 -24
  37. package/src/render/resource-hints.ts +4 -1
  38. package/src/resolve/imports.ts +13 -8
  39. package/src/routing/routes.ts +32 -18
  40. package/src/runtime/loader.ts +29 -10
  41. package/src/runtime/modules.ts +28 -7
  42. package/src/runtime/vendor-build.ts +2 -2
  43. package/src/types.ts +2 -0
  44. package/src/utils/fs.ts +122 -0
@@ -181,6 +181,7 @@ interface ModuleEdge {
181
181
 
182
182
  interface ScanContext {
183
183
  root: string
184
+ workspaceRoot?: string
184
185
  appPath: string
185
186
  sources: Map<string, string>
186
187
  edges: Map<string, ModuleEdge[]>
@@ -198,11 +199,16 @@ interface ScanContext {
198
199
  globalCssImports(): Set<string>
199
200
  }
200
201
 
201
- function createScanContext(appPath: string, globalCss?: () => Set<string>): ScanContext {
202
+ function createScanContext(
203
+ appPath: string,
204
+ globalCss?: () => Set<string>,
205
+ workspaceRoot?: string,
206
+ ): ScanContext {
202
207
  const root = rootFromAppPath(appPath)
203
208
  let memo: Set<string> | undefined
204
209
  return {
205
210
  root,
211
+ workspaceRoot,
206
212
  appPath,
207
213
  sources: new Map(),
208
214
  edges: new Map(),
@@ -213,7 +219,8 @@ function createScanContext(appPath: string, globalCss?: () => Set<string>): Scan
213
219
  usesNavigation: new Map(),
214
220
  entryReasons: new Map(),
215
221
  globalCssImports:
216
- globalCss ?? (() => (memo ??= new Set(globalCssSourcesForPaths(root, appPath)))),
222
+ globalCss ??
223
+ (() => (memo ??= new Set(globalCssSourcesForPaths(root, appPath, workspaceRoot)))),
217
224
  }
218
225
  }
219
226
 
@@ -375,14 +382,21 @@ function withDeferredFacts(
375
382
  return entry
376
383
  }
377
384
 
378
- export async function scanRoutes(appPath: string): Promise<RouteManifestEntry[]> {
385
+ export async function scanRoutes(
386
+ appPath: string,
387
+ workspaceRoot?: string,
388
+ ): Promise<RouteManifestEntry[]> {
379
389
  const files = await listFiles(appPath)
380
- return withDirCache(() => buildRouteTable(appPath, files))
390
+ return withDirCache(() => buildRouteTable(appPath, files, workspaceRoot))
381
391
  }
382
392
 
383
393
  /** The path pass: file paths in, route table out — no file content is read. */
384
- function buildRouteTable(appPath: string, files: string[]): RouteManifestEntry[] {
385
- const context = createScanContext(appPath)
394
+ function buildRouteTable(
395
+ appPath: string,
396
+ files: string[],
397
+ workspaceRoot?: string,
398
+ ): RouteManifestEntry[] {
399
+ const context = createScanContext(appPath, undefined, workspaceRoot)
386
400
  const routes: RouteManifestEntry[] = []
387
401
  const slotPageFiles: { file: string; relative: string }[] = []
388
402
  const defaultPageFiles: { file: string; relative: string }[] = []
@@ -1893,7 +1907,7 @@ function defaultReexportsClientModule(
1893
1907
  if (importBinding?.[1]) specifiers.push(importBinding[1])
1894
1908
  }
1895
1909
  for (const specifier of specifiers) {
1896
- const resolved = resolveModuleEdge(context.root, file, specifier)
1910
+ const resolved = resolveModuleEdge(context, file, specifier)
1897
1911
  if (!resolved || !fileExists(context, resolved)) continue
1898
1912
  if (isClientModule(context, resolved)) return true
1899
1913
  if (defaultReexportsClientModule(context, resolved, visited)) return true
@@ -2126,7 +2140,7 @@ function readScanSource(context: ScanContext, file: string, visited = new Set<st
2126
2140
  const parts = [source]
2127
2141
  for (const edge of scanFacts(file, source).imports) {
2128
2142
  if (!edge.reexport || !edge.specifier.startsWith('.')) continue
2129
- const resolved = resolveModuleEdge(context.root, file, edge.specifier)
2143
+ const resolved = resolveModuleEdge(context, file, edge.specifier)
2130
2144
  if (!resolved || !existsSync(resolved)) continue
2131
2145
  parts.push(readScanSource(context, resolved, visited))
2132
2146
  }
@@ -2274,12 +2288,12 @@ function moduleEdges(context: ScanContext, file: string) {
2274
2288
  // `next` package, plain resolveImport finds the real package's directive-less re-export stub and the
2275
2289
  // 'use client' boundary of the compat module is silently missed, so the route never gets its client
2276
2290
  // reference or entry.
2277
- function resolveModuleEdge(root: string, file: string, specifier: string) {
2291
+ function resolveModuleEdge(context: ScanContext, file: string, specifier: string) {
2278
2292
  return (
2279
2293
  resolveModuleAlias(specifier) ??
2280
- resolveImport(root, file, specifier) ??
2281
- getBundlerExtensions().resolveRouteDependency(root, file, specifier) ??
2282
- getCssExtensions().resolveCssDependency(root, file, specifier)
2294
+ resolveImport(context.root, file, specifier, context.workspaceRoot) ??
2295
+ getBundlerExtensions().resolveRouteDependency(context.root, file, specifier) ??
2296
+ getCssExtensions().resolveCssDependency(context.root, file, specifier)
2283
2297
  )
2284
2298
  }
2285
2299
 
@@ -2288,7 +2302,7 @@ function publishedClientImports(context: ScanContext, file: string) {
2288
2302
  const source = readSource(context, file)
2289
2303
  const found: ModuleEdge[] = []
2290
2304
  for (const edge of scanFacts(file, source).imports) {
2291
- if (resolveModuleEdge(context.root, file, edge.specifier)) continue
2305
+ if (resolveModuleEdge(context, file, edge.specifier)) continue
2292
2306
  found.push(...publishedClientEdges(context, file, edge.specifier, edge.exports))
2293
2307
  }
2294
2308
  return found
@@ -2316,7 +2330,7 @@ function resolvePublishedEdge(
2316
2330
  kind: 'import' | 'require',
2317
2331
  ) {
2318
2332
  return (
2319
- resolveModuleEdge(context.root, file, specifier) ??
2333
+ resolveModuleEdge(context, file, specifier) ??
2320
2334
  resolvePackageSpecifier(context.root, file, specifier, [kind, 'default'])
2321
2335
  )
2322
2336
  }
@@ -2376,7 +2390,7 @@ function moduleEdgesFromSource(context: ScanContext, file: string, source: strin
2376
2390
  const facts = scanFacts(file, source)
2377
2391
 
2378
2392
  for (const edge of facts.imports) {
2379
- const resolved = resolveModuleEdge(context.root, file, edge.specifier)
2393
+ const resolved = resolveModuleEdge(context, file, edge.specifier)
2380
2394
  if (!resolved) continue
2381
2395
  ordered.push({
2382
2396
  file: resolved,
@@ -2389,7 +2403,7 @@ function moduleEdgesFromSource(context: ScanContext, file: string, source: strin
2389
2403
  ordered.sort((a, b) => a.index - b.index)
2390
2404
  const imports: ModuleEdge[] = ordered.map(({ index: _index, ...edge }) => edge)
2391
2405
 
2392
- for (const imported of dynamicImportEdgesFromSource(context.root, file, source, dynamicNames))
2406
+ for (const imported of dynamicImportEdgesFromSource(context, file, source, dynamicNames))
2393
2407
  imports.push(imported)
2394
2408
 
2395
2409
  return imports
@@ -2488,7 +2502,7 @@ function assertSupportedDynamicCalls(file: string, source: string, dynamicNames:
2488
2502
  }
2489
2503
 
2490
2504
  function dynamicImportEdgesFromSource(
2491
- root: string,
2505
+ context: ScanContext,
2492
2506
  file: string,
2493
2507
  source: string,
2494
2508
  dynamicNames: Set<string>,
@@ -2499,7 +2513,7 @@ function dynamicImportEdgesFromSource(
2499
2513
  const optionObjects = dynamicOptionObjects(source)
2500
2514
  for (const fact of dynamicCallFacts(source, file)) {
2501
2515
  if (!dynamicNames.has(fact.name)) continue
2502
- const resolved = resolveModuleEdge(root, file, fact.specifier)
2516
+ const resolved = resolveModuleEdge(context, file, fact.specifier)
2503
2517
  if (!resolved) continue
2504
2518
  imports.push({
2505
2519
  file: resolved,
@@ -187,6 +187,7 @@ export function registerServerRuntime(config: ResolvedConfig, sourceFiles: strin
187
187
 
188
188
  const aliases = aliasesForConfig(config)
189
189
  const roots = [
190
+ ...rootPaths(config.root),
190
191
  ...rootPaths(config.appPath),
191
192
  ...rootPaths(path.join(config.outPath, 'cache', 'server')),
192
193
  // The pnext src root, so the framework's own source transforms too.
@@ -219,7 +220,7 @@ export function registerServerRuntime(config: ResolvedConfig, sourceFiles: strin
219
220
  }
220
221
  registerResolvePlugin()
221
222
  registerNodeModulesLoadPlugin()
222
- for (const root of roots) registerLoadPlugin(root)
223
+ for (const root of roots) registerLoadPlugin(root, rootPaths(config.root).includes(root))
223
224
  }
224
225
 
225
226
  /**
@@ -236,7 +237,12 @@ export function installServerRuntimePlugins(): void {
236
237
  pluginsDeferred = false
237
238
  registerResolvePlugin()
238
239
  registerNodeModulesLoadPlugin()
239
- for (const root of deferredPluginRoots) registerLoadPlugin(root)
240
+ for (const root of deferredPluginRoots) {
241
+ const projectRoot = [...runtimeConfigs.values()].some(config =>
242
+ rootPaths(config.root).includes(root),
243
+ )
244
+ registerLoadPlugin(root, projectRoot)
245
+ }
240
246
  deferredPluginRoots.clear()
241
247
  }
242
248
 
@@ -302,7 +308,7 @@ function registerResolvePlugin() {
302
308
  })
303
309
  }
304
310
 
305
- function registerLoadPlugin(root: string) {
311
+ function registerLoadPlugin(root: string, projectRoot = false) {
306
312
  if (registeredLoadRoots.has(root)) return
307
313
  registeredLoadRoots.add(root)
308
314
  if (traceEnabled('server')) {
@@ -312,7 +318,7 @@ function registerLoadPlugin(root: string) {
312
318
  Bun.plugin({
313
319
  name: `pnext-server-runtime-load-${hashRoot(root)}`,
314
320
  setup(plugin) {
315
- plugin.onLoad({ filter: rootFilter(root) }, async ({ path: file }) => ({
321
+ plugin.onLoad({ filter: rootFilter(root, projectRoot) }, async ({ path: file }) => ({
316
322
  contents: await transformSource(file),
317
323
  loader: 'js',
318
324
  }))
@@ -594,12 +600,17 @@ async function inlineModuleScopeRequires(
594
600
  function requireCallTarget(root: string, file: string, specifier: string): string | undefined {
595
601
  if (specifier.startsWith('file://')) {
596
602
  try {
597
- return resolveImport(root, file, fileURLToPath(specifier))
603
+ return resolveImport(
604
+ root,
605
+ file,
606
+ fileURLToPath(specifier),
607
+ runtimeConfigForFile(file)?.resolved.workspaceRoot,
608
+ )
598
609
  } catch {
599
610
  return undefined
600
611
  }
601
612
  }
602
- return resolveImport(root, file, specifier)
613
+ return resolveImport(root, file, specifier, runtimeConfigForFile(file)?.resolved.workspaceRoot)
603
614
  }
604
615
 
605
616
  // Compile an app module to a sibling `.pnext-require.cjs` (CommonJS), reusing a
@@ -751,7 +762,8 @@ export function rewriteServerSource(
751
762
  const rewritten = sourceHasDynamicImport(source)
752
763
  ? rewriteDynamicCallTargets(
753
764
  rewriteLiteralDynamicCalls(source, file),
754
- specifier => resolveImport(root, file, specifier),
765
+ specifier =>
766
+ resolveImport(root, file, specifier, runtimeConfigForFile(file)?.resolved.workspaceRoot),
755
767
  file,
756
768
  )
757
769
  : source
@@ -867,12 +879,13 @@ function rootPaths(root: string) {
867
879
  return resolved
868
880
  }
869
881
 
870
- function rootFilter(root: string) {
882
+ function rootFilter(root: string, projectRoot = false) {
871
883
  // Base source extensions plus any extra loadable extensions registered by
872
884
  // compat (e.g. mdx/md via pageExtensions). Without these, `.mdx` modules skip
873
885
  // the transform load hook and resolve as raw assets (default export = path).
874
886
  const extras = currentLoadExtras().map(escapeRegex)
875
- const exts = ['[jt]sx?', ...extras].join('|')
887
+ // Project-wide coverage handles JSX/TS; plain JS tools and build output keep their own loaders.
888
+ const exts = [projectRoot ? 'tsx?|jsx' : '[jt]sx?', ...extras].join('|')
876
889
  // Exclude `next.config.{js,cjs,mjs,ts}`: it is loaded for its exported value (often CommonJS
877
890
  // `module.exports = ...`), not as part of the app's server module graph. Routing it through the
878
891
  // ES-module source transform drops the CJS exports, silently disabling next.config
@@ -884,8 +897,14 @@ function rootFilter(root: string) {
884
897
  const vendorGuard = vendorLoadsNatively()
885
898
  ? `${root.endsWith(`${path.sep}cache${path.sep}server`) ? '(?!\\/vendor\\/)' : ''}(?!.*\\/cache\\/server\\/vendor\\/)`
886
899
  : ''
900
+ // Project-wide roots include precompiled server output. Keep its chunks native: transforming
901
+ // them loads compiler libraries and starts an esbuild service on the first production request.
902
+ const serverGuard = [...runtimeConfigs.values()]
903
+ .flatMap(runtime => rootPaths(path.join(runtime.resolved.outPath, 'server')))
904
+ .map(serverRoot => `(?!${escapeRegex(serverRoot)}(?:/|$))`)
905
+ .join('')
887
906
  return new RegExp(
888
- `^${escapeRegex(root)}${vendorGuard}(?!.*\\/node_modules\\/)(?!\\/next\\.config\\.(?:js|cjs|mjs|ts)$)(?:/.*)?\\.(?:${exts})$`,
907
+ `^${serverGuard}${escapeRegex(root)}${vendorGuard}(?!.*\\/node_modules\\/)(?!\\/next\\.config\\.(?:js|cjs|mjs|ts)$)(?:/.*)?\\.(?:${exts})$`,
889
908
  )
890
909
  }
891
910
 
@@ -1420,6 +1420,24 @@ export const compiledSpecifiersManifestSuffix = '.pnext-specifiers.json'
1420
1420
  const compiledScriptFilePattern = /\.(?:m?js|cjs|jsx|tsx?)$/
1421
1421
 
1422
1422
  async function writeCompiledFile(file: string, contents: string) {
1423
+ const profileRoot = compiledArtifactProfileRoot(file)
1424
+ if (profileRoot && contents.includes('file://')) {
1425
+ const cacheRoot = path.dirname(profileRoot)
1426
+ const edits: { start: number; end: number; value: string }[] = []
1427
+ for (const found of outputSpecifiers(contents)) {
1428
+ const { sourcePath, hash } = splitHash(found.value)
1429
+ if (!sourcePath.startsWith('file://')) continue
1430
+ const target = fileURLToPath(sourcePath)
1431
+ if (!isInside(cacheRoot, target)) continue
1432
+ const relative = toPosixPath(path.relative(path.dirname(file), target))
1433
+ edits.push({
1434
+ start: found.start,
1435
+ end: found.end,
1436
+ value: JSON.stringify(`${relative.startsWith('.') ? relative : `./${relative}`}${hash}`),
1437
+ })
1438
+ }
1439
+ contents = spliceSource(contents, edits)
1440
+ }
1423
1441
  await writeFileAtomic(file, contents)
1424
1442
  noteDevArtifactWritten(file)
1425
1443
  if (emitCompiledSpecifiersManifest && compiledScriptFilePattern.test(file)) {
@@ -1431,7 +1449,7 @@ async function writeCompiledFile(file: string, contents: string) {
1431
1449
  }
1432
1450
 
1433
1451
  /**
1434
- * Whether every file URL reachable from a compiled artifact is already on
1452
+ * Whether every module reachable from a compiled artifact is already on
1435
1453
  * disk. Individual files publish atomically, but the graph is a set of files:
1436
1454
  * the build's warm child can expose an importer to the parent before it has
1437
1455
  * exposed that importer's slower sibling. A mere existsSync(importer) is
@@ -1474,10 +1492,12 @@ function missingCompiledArtifact(entry: string): string | undefined {
1474
1492
  // profile directory. Static imports must be complete before evaluation.
1475
1493
  if (kind === 'dynamic') continue
1476
1494
  const { sourcePath } = splitHash(specifier)
1477
- if (!sourcePath.startsWith('file://')) continue
1495
+ if (!sourcePath.startsWith('file://') && !sourcePath.startsWith('.')) continue
1478
1496
  let target: string
1479
1497
  try {
1480
- target = fileURLToPath(sourcePath)
1498
+ target = sourcePath.startsWith('file://')
1499
+ ? fileURLToPath(sourcePath)
1500
+ : path.resolve(path.dirname(file), sourcePath)
1481
1501
  } catch {
1482
1502
  return sourcePath
1483
1503
  }
@@ -2433,6 +2453,7 @@ async function resolveDevModuleSpecifier(
2433
2453
  config.root,
2434
2454
  importer && !importer.startsWith('<') ? importer : file,
2435
2455
  sourcePath,
2456
+ config.workspaceRoot,
2436
2457
  )
2437
2458
  // A relative specifier that resolved names first-party source next to its importer, so compile it
2438
2459
  // through the normal pipeline even outside workspaceRoot (compat's own runtime files live inside
@@ -2806,7 +2827,7 @@ function shouldBundleExternalPackage(
2806
2827
  if (options.aliases[specifier]) return false
2807
2828
  if (specifier === 'server-only') return false
2808
2829
  if (!isPackageSpecifier(specifier)) return false
2809
- const resolved = resolveImport(config.root, file, specifier)
2830
+ const resolved = resolveImport(config.root, file, specifier, config.workspaceRoot)
2810
2831
  const inWorkspace = resolved !== undefined && isInside(config.workspaceRoot, resolved)
2811
2832
  // transpilePackages force-bundle; serverExternalPackages force-external (never bundled). A
2812
2833
  // transpiled package that resolves INSIDE the workspace is first-party source, not a registry
@@ -2859,7 +2880,7 @@ function localImportTargets(
2859
2880
  // compiling them as modules would fail the import scan. Css keeps its
2860
2881
  // copy-through so compiled css module paths stay importable.
2861
2882
  if (isServerIgnoredAssetSpecifier(sourcePath) && !isCssFile(sourcePath)) continue
2862
- const resolved = resolveImport(config.root, file, sourcePath)
2883
+ const resolved = resolveImport(config.root, file, sourcePath, config.workspaceRoot)
2863
2884
  // `resolveExtensions` can land an extensionless specifier on an asset
2864
2885
  // (`import img from './image'` -> image.png), which the specifier check
2865
2886
  // above misses — compiling the image bytes as a module fails the build.
@@ -2894,7 +2915,7 @@ async function writeClientReferenceModules(
2894
2915
  for (const specifier of importSpecifiers(source, file)) {
2895
2916
  const { sourcePath } = splitHash(specifier)
2896
2917
  if (aliases[specifier] || aliases[sourcePath]) continue
2897
- const resolved = resolveImport(config.root, file, sourcePath)
2918
+ const resolved = resolveImport(config.root, file, sourcePath, config.workspaceRoot)
2898
2919
  if (!resolved || !isInside(config.workspaceRoot, resolved) || isCssFile(resolved)) continue
2899
2920
  if (!(await fileHasUseClientDirective(resolved))) continue
2900
2921
 
@@ -3107,7 +3128,7 @@ function packageNameFromSpecifier(specifier: string) {
3107
3128
 
3108
3129
  function resolveLocalImport(config: ResolvedConfig, fromFile: string, specifier: string) {
3109
3130
  if (path.isAbsolute(specifier)) return existsSync(specifier) ? specifier : undefined
3110
- return resolveImport(config.root, fromFile, specifier)
3131
+ return resolveImport(config.root, fromFile, specifier, config.workspaceRoot)
3111
3132
  }
3112
3133
 
3113
3134
  async function linkAssetContext(config: ResolvedConfig, file: string, profile: string) {
@@ -1754,8 +1754,8 @@ function nearestManifestDir(from: string) {
1754
1754
  const manifest = path.join(dir, 'package.json')
1755
1755
  if (existsSync(manifest)) {
1756
1756
  try {
1757
- const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }
1758
- if (typeof parsed.name === 'string') {
1757
+ const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
1758
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1759
1759
  nearestManifestDirs.set(from, dir)
1760
1760
  return dir
1761
1761
  }
package/src/types.ts CHANGED
@@ -658,7 +658,9 @@ export interface StaticRouteMetadata {
658
658
 
659
659
  export interface BuildManifest {
660
660
  version: 0
661
+ /** Source root relative to outDir on disk; resolved at load. */
661
662
  root: string
663
+ /** App directory relative to root on disk; resolved at load. */
662
664
  appDir: string
663
665
  outDir: string
664
666
  routes: RouteManifestEntry[]
package/src/utils/fs.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
2
  import { mkdir, readdir, rename, rm, stat, readFile, writeFile } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
+ import type { BuildManifest } from '../types'
5
6
 
6
7
  // Write-then-rename so a concurrent import never observes a truncated file; Bun caches a failed load for the
7
8
  // life of the process. The temp name must be unique per CALL, not per process: two writers of the same output
@@ -145,6 +146,34 @@ export function toPosixPath(value: string) {
145
146
  return value.split(path.sep).join('/')
146
147
  }
147
148
 
149
+ export function cssModuleScopePath(file: string) {
150
+ const normalized = toPosixPath(file)
151
+ const appIndex = normalized.lastIndexOf('/app/')
152
+ if (appIndex !== -1) return normalized.slice(appIndex + 1)
153
+ let dir = path.dirname(file)
154
+ let fallback: string | undefined
155
+ while (true) {
156
+ const packageFile = path.join(dir, 'package.json')
157
+ if (
158
+ existsSync(packageFile) ||
159
+ existsSync(path.join(dir, 'pnext.config.ts')) ||
160
+ existsSync(path.join(dir, 'node_modules'))
161
+ ) {
162
+ const relative = toPosixPath(path.relative(dir, file))
163
+ try {
164
+ const { name } = JSON.parse(readFileSync(packageFile, 'utf8')) as { name?: unknown }
165
+ if (typeof name === 'string') return `${name}/${relative}`
166
+ } catch {
167
+ // Unnamed projects still have a root-relative identity.
168
+ }
169
+ fallback ??= relative
170
+ }
171
+ const parent = path.dirname(dir)
172
+ if (parent === dir) return fallback ?? path.basename(file)
173
+ dir = parent
174
+ }
175
+ }
176
+
148
177
  /** '^' + pnext's own running version, read from its package.json (this module lives at src/utils/). */
149
178
  export function pnextVersionRange() {
150
179
  const pkgPath = fileURLToPath(new URL('../../package.json', import.meta.url))
@@ -164,3 +193,96 @@ export function pnextOptionalDependencyRange(name: string) {
164
193
  }
165
194
  return pkg.optionalDependencies?.[name]
166
195
  }
196
+
197
+ export function includeTypes(config: { include?: unknown }, files: string[], defaults: string[]) {
198
+ if (!('include' in config)) {
199
+ config.include = [...files, ...defaults]
200
+ return files
201
+ }
202
+ const include = config.include
203
+ if (!Array.isArray(include)) return []
204
+ const added = files.filter(file => !include.includes(file))
205
+ include.push(...added)
206
+ return added
207
+ }
208
+
209
+ /** Relativize only source-path fields while writing; build hooks keep absolute paths. */
210
+ export function serializeBuildManifest(data: BuildManifest): string {
211
+ const { root, appDir, outDir } = data
212
+ const stored = {
213
+ ...mapSourcePaths(data, file => toPosixPath(path.relative(root, file))),
214
+ root: toPosixPath(path.relative(outDir, root)) || '.',
215
+ appDir: toPosixPath(path.relative(root, appDir)),
216
+ outDir: '.',
217
+ }
218
+ return `${JSON.stringify(stored, null, 2)}\n`
219
+ }
220
+
221
+ export function resolveManifest(data: BuildManifest, outPath: string, root: string): BuildManifest {
222
+ if (path.isAbsolute(data.root)) return data
223
+ const absolute = (file: string) => path.join(root, file)
224
+ return {
225
+ ...mapSourcePaths(data, absolute),
226
+ root,
227
+ appDir: absolute(data.appDir),
228
+ outDir: outPath,
229
+ }
230
+ }
231
+
232
+ const sourcePathFields = [
233
+ 'file',
234
+ 'sourceFiles',
235
+ 'cssImports',
236
+ 'templateFiles',
237
+ 'forbiddenFiles',
238
+ 'unauthorizedFiles',
239
+ 'slotDirs',
240
+ 'slotDir',
241
+ 'syntheticSlotDir',
242
+ 'childrenDefault',
243
+ 'serverActionFile',
244
+ 'globalErrorFile',
245
+ ] as const
246
+
247
+ type SourcePathFields = Partial<Record<(typeof sourcePathFields)[number], string | string[]>>
248
+
249
+ function mapSourcePaths(data: BuildManifest, map: (file: string) => string): BuildManifest {
250
+ // Only schema path owners are visited; params and user metadata remain data.
251
+ function owner<T extends SourcePathFields | undefined>(value: T): T {
252
+ if (!value) return value
253
+ const mapped = { ...value }
254
+ for (const key of sourcePathFields) {
255
+ const field = value[key]
256
+ if (field !== undefined) mapped[key] = Array.isArray(field) ? field.map(map) : map(field)
257
+ }
258
+ return mapped
259
+ }
260
+ const mapped = owner(data)
261
+ mapped.routes = data.routes.map(route => ({
262
+ ...owner(route),
263
+ clientReferences: route.clientReferences.map(owner),
264
+ ...(route.interception && { interception: owner(route.interception) }),
265
+ }))
266
+ if (data.staticMetadataFiles) mapped.staticMetadataFiles = data.staticMetadataFiles.map(owner)
267
+ if (data.staticRouteMetadata) {
268
+ mapped.staticRouteMetadata = Object.fromEntries(
269
+ Object.entries(data.staticRouteMetadata).map(([route, entry]) => [
270
+ route,
271
+ {
272
+ ...entry,
273
+ favicon: owner(entry.favicon),
274
+ manifest: owner(entry.manifest),
275
+ icons: entry.icons.map(owner),
276
+ appleIcons: entry.appleIcons.map(owner),
277
+ rootIcons: entry.rootIcons.map(owner),
278
+ },
279
+ ]),
280
+ )
281
+ }
282
+ if (data.staticModuleMetadata) {
283
+ mapped.staticModuleMetadata = Object.fromEntries(
284
+ Object.entries(data.staticModuleMetadata).map(([file, metadata]) => [map(file), metadata]),
285
+ )
286
+ }
287
+ return mapped
288
+ }