@ecopages/core 0.2.0-alpha.25 → 0.2.0-alpha.27

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 (111) hide show
  1. package/README.md +63 -7
  2. package/package.json +4 -47
  3. package/src/adapters/bun/create-app.ts +54 -2
  4. package/src/adapters/bun/hmr-manager.test.ts +0 -2
  5. package/src/adapters/bun/hmr-manager.ts +1 -24
  6. package/src/adapters/bun/server-adapter.ts +30 -4
  7. package/src/adapters/node/node-hmr-manager.test.ts +0 -2
  8. package/src/adapters/node/node-hmr-manager.ts +2 -25
  9. package/src/adapters/shared/explicit-static-render-preparation.ts +58 -0
  10. package/src/adapters/shared/explicit-static-route-matcher.test.ts +6 -6
  11. package/src/adapters/shared/explicit-static-route-matcher.ts +22 -31
  12. package/src/adapters/shared/file-route-middleware-pipeline.test.ts +5 -10
  13. package/src/adapters/shared/file-route-middleware-pipeline.ts +8 -17
  14. package/src/adapters/shared/fs-server-response-factory.test.ts +32 -43
  15. package/src/adapters/shared/fs-server-response-factory.ts +15 -37
  16. package/src/adapters/shared/fs-server-response-matcher.test.ts +65 -39
  17. package/src/adapters/shared/fs-server-response-matcher.ts +94 -43
  18. package/src/adapters/shared/hmr-manager.contract.test.ts +0 -4
  19. package/src/adapters/shared/render-context.ts +3 -3
  20. package/src/adapters/shared/server-adapter.test.ts +53 -0
  21. package/src/adapters/shared/server-adapter.ts +228 -159
  22. package/src/adapters/shared/server-route-handler.test.ts +6 -5
  23. package/src/adapters/shared/server-route-handler.ts +4 -4
  24. package/src/adapters/shared/server-static-builder.test.ts +4 -4
  25. package/src/adapters/shared/server-static-builder.ts +4 -4
  26. package/src/config/README.md +1 -1
  27. package/src/config/config-builder.test.ts +0 -1
  28. package/src/config/config-builder.ts +2 -7
  29. package/src/dev/host-runtime.ts +34 -0
  30. package/src/eco/eco.browser.test.ts +2 -2
  31. package/src/eco/eco.browser.ts +2 -2
  32. package/src/eco/eco.test.ts +6 -6
  33. package/src/eco/eco.ts +12 -12
  34. package/src/eco/eco.types.ts +3 -3
  35. package/src/errors/index.ts +1 -0
  36. package/src/hmr/client/hmr-runtime.ts +4 -2
  37. package/src/hmr/strategies/js-hmr-strategy.test.ts +0 -1
  38. package/src/hmr/strategies/js-hmr-strategy.ts +0 -6
  39. package/src/integrations/ghtml/ghtml-renderer.test.ts +7 -7
  40. package/src/integrations/ghtml/ghtml-renderer.ts +1 -11
  41. package/src/plugins/eco-component-meta-plugin.ts +0 -1
  42. package/src/plugins/integration-plugin.test.ts +9 -14
  43. package/src/plugins/integration-plugin.ts +34 -22
  44. package/src/plugins/processor.ts +17 -0
  45. package/src/route-renderer/GRAPH.md +81 -289
  46. package/src/route-renderer/README.md +67 -105
  47. package/src/route-renderer/orchestration/component-render-context.ts +45 -38
  48. package/src/route-renderer/orchestration/declared-ownership-graph.ts +62 -0
  49. package/src/route-renderer/orchestration/foreign-subtree-execution.service.ts +383 -0
  50. package/src/route-renderer/orchestration/integration-renderer.test.ts +118 -121
  51. package/src/route-renderer/orchestration/integration-renderer.ts +362 -403
  52. package/src/route-renderer/orchestration/ownership-planning.service.ts +97 -0
  53. package/src/route-renderer/orchestration/ownership-validation.service.ts +76 -0
  54. package/src/route-renderer/orchestration/processed-asset-dedupe.ts +1 -1
  55. package/src/route-renderer/orchestration/{queued-boundary-runtime.service.test.ts → queued-foreign-subtree-resolution.service.test.ts} +76 -71
  56. package/src/route-renderer/orchestration/{queued-boundary-runtime.service.ts → queued-foreign-subtree-resolution.service.ts} +68 -63
  57. package/src/route-renderer/orchestration/render-output.utils.ts +21 -13
  58. package/src/route-renderer/orchestration/{render-preparation.service.test.ts → route-render-orchestrator.prepare-render-options.test.ts} +160 -85
  59. package/src/route-renderer/orchestration/route-render-orchestrator.test.ts +265 -0
  60. package/src/route-renderer/orchestration/{render-preparation.service.ts → route-render-orchestrator.ts} +244 -160
  61. package/src/route-renderer/page-loading/component-dependency-collection.ts +9 -3
  62. package/src/route-renderer/page-loading/declared-asset-collection.ts +2 -5
  63. package/src/route-renderer/page-loading/dependency-resolver.test.ts +107 -11
  64. package/src/route-renderer/page-loading/dependency-resolver.ts +6 -12
  65. package/src/route-renderer/page-loading/ecopages-virtual-imports.ts +1 -1
  66. package/src/route-renderer/page-loading/lazy-entry-collection.ts +1 -1
  67. package/src/route-renderer/page-loading/lazy-trigger-planning.ts +1 -1
  68. package/src/route-renderer/page-loading/module-declaration-aggregation.ts +1 -1
  69. package/src/route-renderer/page-loading/module-declaration-scripts.ts +1 -1
  70. package/src/route-renderer/page-loading/page-dependency-bundling.ts +105 -66
  71. package/src/route-renderer/route-renderer.ts +28 -31
  72. package/src/router/README.md +16 -19
  73. package/src/router/server/route-registry.test.ts +176 -0
  74. package/src/router/server/route-registry.ts +382 -0
  75. package/src/services/README.md +1 -2
  76. package/src/services/assets/asset-processing-service/asset-dependency-keys.ts +1 -1
  77. package/src/services/assets/asset-processing-service/asset-processing.service.test.ts +1 -4
  78. package/src/services/assets/asset-processing-service/asset-processing.service.ts +1 -2
  79. package/src/services/assets/asset-processing-service/assets.types.ts +3 -0
  80. package/src/services/assets/asset-processing-service/grouped-content-bundles.ts +1 -1
  81. package/src/services/assets/asset-processing-service/index.ts +1 -0
  82. package/src/{route-renderer/orchestration/page-packaging.service.test.ts → services/assets/asset-processing-service/page-package.test.ts} +38 -14
  83. package/src/services/assets/asset-processing-service/page-package.ts +93 -0
  84. package/src/services/assets/asset-processing-service/processors/base/base-script-processor.ts +4 -5
  85. package/src/services/assets/asset-processing-service/processors/script/content-script.processor.test.ts +13 -10
  86. package/src/services/assets/asset-processing-service/processors/script/content-script.processor.ts +3 -0
  87. package/src/services/assets/asset-processing-service/processors/script/file-script.processor.ts +6 -0
  88. package/src/services/assets/asset-processing-service/processors/script/node-module-script.processor.ts +2 -0
  89. package/src/services/assets/asset-processing-service/processors/stylesheet/content-stylesheet.processor.ts +1 -0
  90. package/src/services/assets/asset-processing-service/processors/stylesheet/file-stylesheet.processor.ts +2 -0
  91. package/src/services/assets/asset-processing-service/ungrouped-dependency-processing.ts +1 -1
  92. package/src/services/html/html-transformer.service.test.ts +1 -4
  93. package/src/services/module-loading/app-server-module-transpiler.service.ts +1 -3
  94. package/src/services/module-loading/node-bootstrap-plugin.ts +17 -3
  95. package/src/services/module-loading/page-module-import.service.ts +0 -1
  96. package/src/services/module-loading/source-module-support.ts +1 -1
  97. package/src/static-site-generator/static-site-generator.test.ts +124 -32
  98. package/src/static-site-generator/static-site-generator.ts +168 -185
  99. package/src/types/internal-types.ts +13 -12
  100. package/src/types/public-types.ts +55 -39
  101. package/src/watchers/project-watcher.test-helpers.ts +4 -3
  102. package/src/route-renderer/orchestration/boundary-planning.service.ts +0 -146
  103. package/src/route-renderer/orchestration/page-packaging.service.ts +0 -85
  104. package/src/route-renderer/orchestration/render-execution.service.test.ts +0 -196
  105. package/src/route-renderer/orchestration/render-execution.service.ts +0 -182
  106. package/src/route-renderer/orchestration/route-shell-composer.service.ts +0 -162
  107. package/src/router/server/fs-router-scanner.test.ts +0 -83
  108. package/src/router/server/fs-router-scanner.ts +0 -224
  109. package/src/router/server/fs-router.test.ts +0 -214
  110. package/src/router/server/fs-router.ts +0 -122
  111. package/src/services/runtime-state/runtime-specifier-registry.service.ts +0 -96
@@ -203,28 +203,38 @@ describe('DependencyResolverService', () => {
203
203
  file: componentFile,
204
204
  },
205
205
  dependencies: {
206
- scripts: ['./widget.script.ts', './other.ts', { src: './lazy.ts', lazy: { 'on:interaction': 'click' } }],
206
+ scripts: [
207
+ './widget.script.ts',
208
+ './other.ts',
209
+ { src: './lazy.ts', lazy: { 'on:interaction': 'click' } },
210
+ ],
207
211
  },
208
212
  };
209
213
 
210
214
  try {
211
215
  await service.processComponentDependencies([component], 'ecopages-jsx');
212
216
 
217
+ const fileScripts = capturedDeps.filter(
218
+ (dep): dep is Extract<AssetDefinition, { kind: 'script'; source: 'file' }> =>
219
+ dep.kind === 'script' && dep.source === 'file',
220
+ );
213
221
  const contentScripts = capturedDeps.filter(
214
222
  (dep): dep is Extract<AssetDefinition, { kind: 'script'; source: 'content' }> =>
215
223
  dep.kind === 'script' && dep.source === 'content',
216
224
  );
217
225
 
218
- expect(contentScripts).toHaveLength(2);
219
-
220
- const pageScript = contentScripts.find((dep) => dep.packageRole === 'page-script');
221
- const lazyEntry = contentScripts.find((dep) => dep.excludeFromHtml === true);
222
-
223
- expect(pageScript).toEqual(
226
+ expect(fileScripts).toEqual([
224
227
  expect.objectContaining({
225
- content: `import ${JSON.stringify(widgetScript)};\nimport ${JSON.stringify(siblingScript)};`,
228
+ filepath: widgetScript,
226
229
  }),
227
- );
230
+ expect.objectContaining({
231
+ filepath: siblingScript,
232
+ }),
233
+ ]);
234
+
235
+ expect(contentScripts).toHaveLength(1);
236
+
237
+ const lazyEntry = contentScripts.find((dep) => dep.excludeFromHtml === true);
228
238
 
229
239
  expect(lazyEntry).toEqual(
230
240
  expect.objectContaining({
@@ -236,7 +246,6 @@ describe('DependencyResolverService', () => {
236
246
  }),
237
247
  );
238
248
 
239
- expect(pageScript?.groupedBundle).toBeUndefined();
240
249
  expect(lazyEntry?.groupedBundle).toBeUndefined();
241
250
  } finally {
242
251
  rmSync(tempDir, { recursive: true, force: true });
@@ -248,7 +257,11 @@ describe('DependencyResolverService', () => {
248
257
  const componentFile = join(tempDir, 'component.tsx');
249
258
  const lazyScript = join(tempDir, 'theme-toggle.script.ts');
250
259
 
251
- writeFileSync(componentFile, "import './theme-toggle.script';\nexport const Component = () => null;\n", 'utf-8');
260
+ writeFileSync(
261
+ componentFile,
262
+ "import './theme-toggle.script';\nexport const Component = () => null;\n",
263
+ 'utf-8',
264
+ );
252
265
  writeFileSync(lazyScript, 'export const themeToggle = true;\n', 'utf-8');
253
266
 
254
267
  let capturedDeps: AssetDefinition[] = [];
@@ -420,6 +433,8 @@ describe('DependencyResolverService', () => {
420
433
  });
421
434
 
422
435
  it('should collapse bundleable page stylesheets and scripts into page-owned assets', async () => {
436
+ const previousNodeEnv = process.env.NODE_ENV;
437
+ process.env.NODE_ENV = 'production';
423
438
  const tempDir = mkdtempSync(join(tmpdir(), 'ecopages-page-bundle-'));
424
439
  const componentFile = join(tempDir, 'component.tsx');
425
440
  const stylesheetA = join(tempDir, 'first.css');
@@ -473,15 +488,96 @@ describe('DependencyResolverService', () => {
473
488
  source: 'content',
474
489
  packageRole: 'page-style',
475
490
  content: '.first { color: red; }\n.second { color: blue; }',
491
+ bundledSourceFilepaths: [stylesheetA, stylesheetB],
476
492
  }),
477
493
  expect.objectContaining({
478
494
  kind: 'script',
479
495
  source: 'content',
480
496
  packageRole: 'page-script',
481
497
  content: `import ${JSON.stringify(scriptA)};\nimport ${JSON.stringify(scriptB)};`,
498
+ bundledSourceFilepaths: [scriptA, scriptB],
499
+ }),
500
+ ]);
501
+ } finally {
502
+ process.env.NODE_ENV = previousNodeEnv;
503
+ rmSync(tempDir, { recursive: true, force: true });
504
+ }
505
+ });
506
+
507
+ it('should keep bundleable page stylesheets and scripts as file assets during development', async () => {
508
+ const previousNodeEnv = process.env.NODE_ENV;
509
+ process.env.NODE_ENV = 'development';
510
+ const tempDir = mkdtempSync(join(tmpdir(), 'ecopages-page-style-dev-'));
511
+ const componentFile = join(tempDir, 'component.tsx');
512
+ const stylesheetA = join(tempDir, 'first.css');
513
+ const stylesheetB = join(tempDir, 'second.css');
514
+ const scriptA = join(tempDir, 'first.ts');
515
+ const scriptB = join(tempDir, 'second.ts');
516
+
517
+ writeFileSync(componentFile, 'export const Component = () => null;', 'utf-8');
518
+ writeFileSync(stylesheetA, '.first { color: red; }', 'utf-8');
519
+ writeFileSync(stylesheetB, '.second { color: blue; }', 'utf-8');
520
+ writeFileSync(scriptA, 'export const first = true;', 'utf-8');
521
+ writeFileSync(scriptB, 'export const second = true;', 'utf-8');
522
+
523
+ let capturedDeps: AssetDefinition[] = [];
524
+ const assetProcessingService = {
525
+ processDependencies: vi.fn(async (deps: AssetDefinition[]) => {
526
+ capturedDeps = deps;
527
+ return [] as ProcessedAsset[];
528
+ }),
529
+ } as unknown as AssetProcessingService;
530
+
531
+ const bundleAppConfig = {
532
+ ...appConfig,
533
+ rootDir: tempDir,
534
+ absolutePaths: {
535
+ srcDir: tempDir,
536
+ distDir: join(tempDir, '.eco/public'),
537
+ },
538
+ } as EcoPagesAppConfig;
539
+
540
+ const service = new DependencyResolverService(bundleAppConfig, assetProcessingService);
541
+ const component = ((_) => '<div></div>') as EcoComponent<Record<string, unknown>>;
542
+ component.config = {
543
+ __eco: {
544
+ id: 'page-style-dev',
545
+ integration: 'react',
546
+ file: componentFile,
547
+ },
548
+ dependencies: {
549
+ stylesheets: ['./first.css', './second.css'],
550
+ scripts: ['./first.ts', './second.ts'],
551
+ },
552
+ };
553
+
554
+ try {
555
+ await service.processComponentDependencies([component], 'react');
556
+
557
+ expect(capturedDeps).toEqual([
558
+ expect.objectContaining({
559
+ kind: 'stylesheet',
560
+ source: 'file',
561
+ filepath: stylesheetA,
562
+ }),
563
+ expect.objectContaining({
564
+ kind: 'stylesheet',
565
+ source: 'file',
566
+ filepath: stylesheetB,
567
+ }),
568
+ expect.objectContaining({
569
+ kind: 'script',
570
+ source: 'file',
571
+ filepath: scriptA,
572
+ }),
573
+ expect.objectContaining({
574
+ kind: 'script',
575
+ source: 'file',
576
+ filepath: scriptB,
482
577
  }),
483
578
  ]);
484
579
  } finally {
580
+ process.env.NODE_ENV = previousNodeEnv;
485
581
  rmSync(tempDir, { recursive: true, force: true });
486
582
  }
487
583
  });
@@ -1,18 +1,12 @@
1
1
  import path from 'node:path';
2
2
  import type { EcoComponent } from '../../types/public-types.ts';
3
3
  import type { EcoPagesAppConfig } from '../../types/internal-types.ts';
4
- import type {
5
- AssetProcessingService,
6
- ProcessedAsset,
7
- } from '../../services/assets/asset-processing-service/index.ts';
4
+ import type { AssetProcessingService, ProcessedAsset } from '../../services/assets/asset-processing-service/index.ts';
8
5
  import { rapidhash } from '../../utils/hash.ts';
9
6
  import { AssetFactory } from '../../services/assets/asset-processing-service/index.ts';
10
- import {
11
- buildResolvedLazyTriggers,
12
- type ResolvedLazyGroup,
13
- } from './lazy-trigger-planning.ts';
7
+ import { buildResolvedLazyTriggers, type ResolvedLazyGroup } from './lazy-trigger-planning.ts';
14
8
  import { collectComponentDependencies } from './component-dependency-collection.ts';
15
- import { createUnifiedPageDependencies } from './page-dependency-bundling.ts';
9
+ import { packagePageDependencies } from './page-dependency-bundling.ts';
16
10
 
17
11
  export const DEPENDENCY_ERRORS = {
18
12
  INVALID_STYLESHEET_ENTRY: 'Invalid stylesheet dependency entry: expected src or content',
@@ -104,13 +98,13 @@ export class DependencyResolverService {
104
98
  },
105
99
  });
106
100
 
107
- const unifiedDependencies = createUnifiedPageDependencies(dependencies, integrationName);
108
- const hasLazyDependencies = unifiedDependencies.some(
101
+ const packagedDependencies = packagePageDependencies(dependencies, integrationName);
102
+ const hasLazyDependencies = packagedDependencies.some(
109
103
  (dep) => dep.kind === 'script' && dep.excludeFromHtml === true,
110
104
  );
111
105
 
112
106
  const processedDependencies = await this.assetProcessingService.processDependencies(
113
- unifiedDependencies,
107
+ packagedDependencies,
114
108
  integrationName,
115
109
  );
116
110
  const lazyKeyToOutputUrl = new Map<string, string>();
@@ -72,4 +72,4 @@ export function extractEcopagesVirtualImports(file: string): EcopagesVirtualImpo
72
72
  from,
73
73
  imports: importsSet ? Array.from(importsSet) : undefined,
74
74
  }));
75
- }
75
+ }
@@ -164,4 +164,4 @@ export function collectLazyScriptEntries(options: CollectLazyEntriesOptions): vo
164
164
  fallbackUrl,
165
165
  });
166
166
  }
167
- }
167
+ }
@@ -71,4 +71,4 @@ export function buildResolvedLazyTriggers(
71
71
  });
72
72
 
73
73
  return [{ triggerId, rules }];
74
- }
74
+ }
@@ -57,4 +57,4 @@ export function collectModuleDeclarations(
57
57
  for (const declaration of autoVirtualImports) {
58
58
  mergeModuleDeclaration(modulesMap, declaration);
59
59
  }
60
- }
60
+ }
@@ -13,4 +13,4 @@ export function createNamedImportModuleSource(from: string, imports: string[]):
13
13
 
14
14
  export function createNamespaceImportModuleSource(from: string): string {
15
15
  return `export * from '${from}';`;
16
- }
16
+ }
@@ -47,7 +47,7 @@ function normalizeCssReferenceToken(token: string): string {
47
47
  * working once multiple files are collapsed into one page-owned bundle.
48
48
  */
49
49
  function isSafeBundledStylesheetContent(content: string): boolean {
50
- for (const match of content.matchAll(/@import\s+(?:url\()?['"]?([^'"\)\s]+)['"]?\)?/g)) {
50
+ for (const match of content.matchAll(/@import\s+(?:url\()?['"]?([^'")\s]+)['"]?\)?/g)) {
51
51
  const reference = normalizeCssReferenceToken(match[1] ?? '');
52
52
  if (reference && !isSafeBundledStylesheetReference(reference)) {
53
53
  return false;
@@ -72,6 +72,13 @@ function isBundleableFileScriptAsset(dependency: AssetDefinition): dependency is
72
72
  return dependency.kind === 'script' && dependency.source === 'file';
73
73
  }
74
74
 
75
+ type PageDependencyPackagingPlan = {
76
+ bundledStylesheet?: AssetDefinition;
77
+ bundledScript?: AssetDefinition;
78
+ bundleableStyleFilepaths: Set<string>;
79
+ bundleableScriptFilepaths: Set<string>;
80
+ };
81
+
75
82
  /**
76
83
  * Returns whether the current integration should collapse eligible page assets into
77
84
  * page-owned bundle entries.
@@ -80,53 +87,57 @@ export function shouldBundlePageDependencies(integrationName: string): boolean {
80
87
  return integrationName === 'react' || integrationName === 'ecopages-jsx';
81
88
  }
82
89
 
83
- /**
84
- * Rewrites eligible flat dependency declarations into page-owned stylesheet and script bundles.
85
- *
86
- * Only assets with the default attribute shape and without explicit packaging roles are
87
- * collapsed so integration-specific or lazy behavior keeps its existing ownership model.
88
- */
89
- export function createUnifiedPageDependencies(
90
+ function createPageDependencyPackagingPlan(
90
91
  dependencies: AssetDefinition[],
91
92
  integrationName: string,
92
- ): AssetDefinition[] {
93
- if (!shouldBundlePageDependencies(integrationName)) {
94
- return dependencies;
95
- }
93
+ ): PageDependencyPackagingPlan | undefined {
94
+ const shouldBundleDependencies = process.env.NODE_ENV === 'production';
96
95
 
97
- const bundleableStyles = dependencies.filter((dependency) => {
98
- if (dependency.kind !== 'stylesheet' || dependency.inline || dependency.position === 'body') {
99
- return false;
100
- }
96
+ const bundleableStyles = dependencies
97
+ .filter((dependency) => {
98
+ if (!shouldBundleDependencies) {
99
+ return false;
100
+ }
101
101
 
102
- if (dependency.packageRole || !hasOnlyExpectedAttributes(dependency.attributes, { rel: 'stylesheet' })) {
103
- return false;
104
- }
102
+ if (dependency.kind !== 'stylesheet' || dependency.inline || dependency.position === 'body') {
103
+ return false;
104
+ }
105
105
 
106
- if (dependency.source === 'content') {
107
- return isSafeBundledStylesheetContent(dependency.content);
108
- }
106
+ if (dependency.packageRole || !hasOnlyExpectedAttributes(dependency.attributes, { rel: 'stylesheet' })) {
107
+ return false;
108
+ }
109
109
 
110
- if (!existsSync(dependency.filepath)) {
111
- return false;
112
- }
110
+ if (dependency.source === 'content') {
111
+ return isSafeBundledStylesheetContent(dependency.content);
112
+ }
113
113
 
114
- return isSafeBundledStylesheetContent(readFileSync(dependency.filepath, 'utf8'));
115
- }).filter(isBundleableFileStylesheetAsset);
114
+ if (!existsSync(dependency.filepath)) {
115
+ return false;
116
+ }
116
117
 
117
- const bundleableScripts = dependencies.filter((dependency) => {
118
- return (
119
- dependency.kind === 'script' &&
120
- dependency.source === 'file' &&
121
- !dependency.inline &&
122
- !dependency.excludeFromHtml &&
123
- dependency.position !== 'body' &&
124
- !dependency.packageRole &&
125
- dependency.bundle !== false &&
126
- hasOnlyExpectedAttributes(dependency.attributes, { type: 'module', defer: '' }) &&
127
- existsSync(dependency.filepath)
128
- );
129
- }).filter(isBundleableFileScriptAsset);
118
+ return isSafeBundledStylesheetContent(readFileSync(dependency.filepath, 'utf8'));
119
+ })
120
+ .filter(isBundleableFileStylesheetAsset);
121
+
122
+ const bundleableScripts = dependencies
123
+ .filter((dependency) => {
124
+ if (!shouldBundleDependencies) {
125
+ return false;
126
+ }
127
+
128
+ return (
129
+ dependency.kind === 'script' &&
130
+ dependency.source === 'file' &&
131
+ !dependency.inline &&
132
+ !dependency.excludeFromHtml &&
133
+ dependency.position !== 'body' &&
134
+ !dependency.packageRole &&
135
+ dependency.bundle !== false &&
136
+ hasOnlyExpectedAttributes(dependency.attributes, { type: 'module', defer: '' }) &&
137
+ existsSync(dependency.filepath)
138
+ );
139
+ })
140
+ .filter(isBundleableFileScriptAsset);
130
141
 
131
142
  const bundledStylesheet =
132
143
  bundleableStyles.length > 1
@@ -135,56 +146,65 @@ export function createUnifiedPageDependencies(
135
146
  position: 'head',
136
147
  attributes: { rel: 'stylesheet' },
137
148
  packageRole: 'page-style',
149
+ bundledSourceFilepaths: bundleableStyles.map((dependency) => dependency.filepath),
138
150
  })
139
151
  : undefined;
140
152
 
141
- const bundleableStyleFilepaths = new Set(bundleableStyles.map((dependency) => dependency.filepath));
142
153
  const pageScriptImports = [...new Set(bundleableScripts.map((dependency) => dependency.filepath))];
143
- const bundleableScriptFilepaths = new Set(pageScriptImports);
144
-
145
154
  const shouldBundlePageScript = pageScriptImports.length > 0 && bundleableScripts.length > 1;
146
-
147
- const bundledScript =
148
- shouldBundlePageScript
149
- ? AssetFactory.createContentScript({
150
- name: `${integrationName}-page-${rapidhash(pageScriptImports.join('|')).toString(16)}`,
151
- content: pageScriptImports.map((filepath) => `import ${JSON.stringify(filepath)};`).join('\n'),
152
- position: 'head',
153
- attributes: { type: 'module', defer: '' },
154
- packageRole: 'page-script',
155
- })
156
- : undefined;
155
+ const bundledScript = shouldBundlePageScript
156
+ ? AssetFactory.createContentScript({
157
+ name: `${integrationName}-page-${rapidhash(pageScriptImports.join('|')).toString(16)}`,
158
+ content: pageScriptImports.map((filepath) => `import ${JSON.stringify(filepath)};`).join('\n'),
159
+ position: 'head',
160
+ attributes: { type: 'module', defer: '' },
161
+ packageRole: 'page-script',
162
+ bundledSourceFilepaths: pageScriptImports,
163
+ })
164
+ : undefined;
157
165
 
158
166
  if (!bundledStylesheet && !bundledScript) {
159
- return dependencies;
167
+ return undefined;
160
168
  }
161
169
 
170
+ return {
171
+ bundledStylesheet,
172
+ bundledScript,
173
+ bundleableStyleFilepaths: new Set(bundleableStyles.map((dependency) => dependency.filepath)),
174
+ bundleableScriptFilepaths: new Set(pageScriptImports),
175
+ };
176
+ }
177
+
178
+ function applyPageDependencyPackagingPlan(
179
+ dependencies: AssetDefinition[],
180
+ plan: PageDependencyPackagingPlan,
181
+ ): AssetDefinition[] {
162
182
  const unifiedDependencies: AssetDefinition[] = [];
163
183
  let insertedStylesheet = false;
164
184
  let insertedScript = false;
165
185
 
166
186
  for (const dependency of dependencies) {
167
187
  if (
168
- bundledStylesheet &&
188
+ plan.bundledStylesheet &&
169
189
  dependency.kind === 'stylesheet' &&
170
190
  dependency.source === 'file' &&
171
- bundleableStyleFilepaths.has(dependency.filepath)
191
+ plan.bundleableStyleFilepaths.has(dependency.filepath)
172
192
  ) {
173
193
  if (!insertedStylesheet) {
174
- unifiedDependencies.push(bundledStylesheet);
194
+ unifiedDependencies.push(plan.bundledStylesheet);
175
195
  insertedStylesheet = true;
176
196
  }
177
197
  continue;
178
198
  }
179
199
 
180
200
  if (
181
- bundledScript &&
201
+ plan.bundledScript &&
182
202
  dependency.kind === 'script' &&
183
203
  dependency.source === 'file' &&
184
- bundleableScriptFilepaths.has(dependency.filepath)
204
+ plan.bundleableScriptFilepaths.has(dependency.filepath)
185
205
  ) {
186
206
  if (!insertedScript) {
187
- unifiedDependencies.push(bundledScript);
207
+ unifiedDependencies.push(plan.bundledScript);
188
208
  insertedScript = true;
189
209
  }
190
210
  continue;
@@ -193,13 +213,32 @@ export function createUnifiedPageDependencies(
193
213
  unifiedDependencies.push(dependency);
194
214
  }
195
215
 
196
- if (bundledScript && !insertedScript) {
197
- unifiedDependencies.push(bundledScript);
216
+ if (plan.bundledScript && !insertedScript) {
217
+ unifiedDependencies.push(plan.bundledScript);
198
218
  }
199
219
 
200
- if (bundledStylesheet && !insertedStylesheet) {
201
- unifiedDependencies.push(bundledStylesheet);
220
+ if (plan.bundledStylesheet && !insertedStylesheet) {
221
+ unifiedDependencies.push(plan.bundledStylesheet);
202
222
  }
203
223
 
204
224
  return unifiedDependencies;
205
- }
225
+ }
226
+
227
+ /**
228
+ * Rewrites eligible flat dependency declarations into page-owned stylesheet and script bundles.
229
+ *
230
+ * Only assets with the default attribute shape and without explicit packaging roles are
231
+ * collapsed so integration-specific or lazy behavior keeps its existing ownership model.
232
+ */
233
+ export function packagePageDependencies(dependencies: AssetDefinition[], integrationName: string): AssetDefinition[] {
234
+ if (!shouldBundlePageDependencies(integrationName)) {
235
+ return dependencies;
236
+ }
237
+
238
+ const plan = createPageDependencyPackagingPlan(dependencies, integrationName);
239
+ if (!plan) {
240
+ return dependencies;
241
+ }
242
+
243
+ return applyPageDependencyPackagingPlan(dependencies, plan);
244
+ }
@@ -1,43 +1,41 @@
1
1
  import type { EcoPagesAppConfig } from '../types/internal-types.ts';
2
2
  import type { IntegrationPlugin } from '../plugins/integration-plugin.ts';
3
- import type { EcoPageFile, RouteRenderResult, RouteRendererOptions } from '../types/public-types.ts';
4
3
  import { invariant } from '../utils/invariant.ts';
5
4
  import { PathUtils } from '../utils/path-utils.module.ts';
6
- import type { IntegrationRenderer, RouteModuleLoadOptions } from './orchestration/integration-renderer.ts';
5
+ import type { IntegrationRenderer } from './orchestration/integration-renderer.ts';
7
6
 
8
7
  /**
9
- * Thin wrapper around one initialized integration renderer.
8
+ * Narrow route-render contract exposed to higher-level routing code.
10
9
  *
11
10
  * @remarks
12
- * This type exists so higher-level routing code can ask for a route renderer
13
- * without depending on the full integration plugin lifecycle. It delegates all
14
- * real work to the integration-specific renderer selected by the factory.
11
+ * Higher-level routing code only needs request execution and page-module
12
+ * loading. Returning this narrowed shape avoids a dedicated wrapper class while
13
+ * still keeping callers off the full integration renderer surface.
15
14
  */
16
- export class RouteRenderer {
17
- private renderer: IntegrationRenderer;
15
+ export type PageRouteRenderer = Pick<IntegrationRenderer, 'execute' | 'loadPageModule'>;
18
16
 
19
- /**
20
- * Creates a route renderer bound to one integration renderer instance.
21
- */
22
- constructor(renderer: IntegrationRenderer) {
23
- this.renderer = renderer;
24
- }
17
+ /**
18
+ * Narrow explicit-view render contract exposed to static route handling.
19
+ *
20
+ * @remarks
21
+ * Explicit static routes only need `renderToResponse()`, so the factory can
22
+ * hide the broader integration renderer surface there as well.
23
+ */
24
+ export type ExplicitViewRenderer = Pick<IntegrationRenderer, 'renderToResponse'>;
25
25
 
26
- /**
27
- * Executes the render pipeline for one matched route.
28
- */
29
- async createRoute(options: RouteRendererOptions): Promise<RouteRenderResult> {
30
- return this.renderer.execute(options);
31
- }
26
+ export interface PageRendererResolver {
27
+ getPageRenderer(filePath: string): PageRouteRenderer;
28
+ }
32
29
 
33
- /**
34
- * Loads the route module through the owning integration renderer.
35
- */
36
- async loadPageModule(filePath: string, options?: RouteModuleLoadOptions): Promise<EcoPageFile> {
37
- return this.renderer.loadPageModule(filePath, options);
38
- }
30
+ export interface ExplicitViewRendererResolver {
31
+ getExplicitViewRenderer(integrationName: string): ExplicitViewRenderer | null;
39
32
  }
40
33
 
34
+ /**
35
+ * Combined renderer-factory contract used by static generation.
36
+ */
37
+ export type StaticGenerationRendererResolver = PageRendererResolver & ExplicitViewRendererResolver;
38
+
41
39
  /**
42
40
  * Selects and caches integration renderers for route files and explicit views.
43
41
  *
@@ -72,17 +70,16 @@ export class RouteRendererFactory {
72
70
  /**
73
71
  * Returns a route renderer for the supplied route file.
74
72
  */
75
- createRenderer(filePath: string): RouteRenderer {
73
+ getPageRenderer(filePath: string): PageRouteRenderer {
76
74
  const integrationRenderer = this.getRouteRendererEngine(filePath);
77
75
  invariant(!!integrationRenderer, `No integration renderer found for file: ${filePath}`);
78
- return new RouteRenderer(integrationRenderer);
76
+ return integrationRenderer;
79
77
  }
80
78
 
81
79
  /**
82
- * Get an integration renderer by its name.
83
- * Used for explicit routing where views specify their integration via __eco.integration.
80
+ * Returns a renderer for an explicit view integration.
84
81
  */
85
- getRendererByIntegration(integrationName: string): IntegrationRenderer | null {
82
+ getExplicitViewRenderer(integrationName: string): ExplicitViewRenderer | null {
86
83
  const integrationPlugin = this.appConfig.integrations.find((plugin) => plugin.name === integrationName);
87
84
  if (!integrationPlugin) {
88
85
  return null;
@@ -8,9 +8,9 @@ The router layer determines what route is being handled and how the client runti
8
8
 
9
9
  It is responsible for:
10
10
 
11
- - filesystem route scanning and classification (`exact`, `dynamic`, `catch-all`)
12
- - matching incoming request URLs to discovered routes
13
- - server-side static-path expansion for dynamic routes
11
+ - filesystem route discovery and classification (`exact`, `dynamic`, `catch-all`)
12
+ - matching incoming request URLs to canonical template routes
13
+ - server-side static-path expansion for dynamic template routes
14
14
  - client-side navigation ownership and cross-runtime handoff
15
15
  - keeping route discovery separate from rendering execution
16
16
 
@@ -18,9 +18,8 @@ It is responsible for:
18
18
 
19
19
  ```
20
20
  router/
21
- ├── server/ # Server-side route scanning and matching
22
- ├── fs-router-scanner.ts # Scans the filesystem and classifies routes
23
- │ └── fs-router.ts # Matches request URLs to discovered routes
21
+ ├── server/ # Server-side route discovery and matching
22
+ └── route-registry.ts # Owns template routes, request matching, static expansion, and reload
24
23
  └── client/ # Browser-side navigation coordination
25
24
  ├── navigation-coordinator.ts # Singleton runtime coordinator
26
25
  └── link-intent.ts # Shared anchor detection and intent recovery helpers
@@ -28,9 +27,9 @@ router/
28
27
 
29
28
  ## `server/`
30
29
 
31
- ### `FSRouterScanner`
30
+ ### `RouteRegistry`
32
31
 
33
- Walks the pages directory and builds a `Routes` map keyed by route pathname.
32
+ Owns the canonical set of filesystem-discovered template routes for one application.
34
33
 
35
34
  File patterns determine route kind:
36
35
 
@@ -40,13 +39,17 @@ File patterns determine route kind:
40
39
  | `[slug].tsx` | `dynamic` | `/blog/[slug]` |
41
40
  | `[...slug].tsx` | `catch-all` | `/docs/[...slug]` |
42
41
 
43
- For `dynamic` routes, the scanner checks whether the page module exports `getStaticPaths`. If present, every returned path is expanded into a concrete `exact`-style route at scan time. In build mode, both `getStaticPaths` and `getStaticProps` are required or an invariant is thrown.
42
+ The registry stores canonical template routes only. It compiles request-time matching metadata during `init()` and `reload()`, but it does not execute `staticPaths()` during discovery.
44
43
 
45
- Catch-all routes are registered but skipped during static generation with a warning.
44
+ Build-time static expansion is a separate operation. The registry invokes `staticPaths()` lazily through an injected page-module adapter and returns concrete static path expansions for the static generator.
46
45
 
47
- ### `FSRouter`
46
+ The public interface is intentionally small:
48
47
 
49
- Holds the scanned `Routes` map and exposes a `match(requestUrl)` method used by adapters.
48
+ - `templateRoutes` ordered readonly template routes
49
+ - `init()` / `reload()` — rebuild discovery state and match metadata
50
+ - `matchRequest(requestUrl)` — request-time matching result with requested pathname, matched template route, params, and query
51
+ - `listStaticPathExpansions()` — build-time expansion for dynamic routes
52
+ - `listStaticGenerationRoutes()` — build-time route planning for static generation across exact and expanded routes
50
53
 
51
54
  Match priority:
52
55
 
@@ -54,12 +57,6 @@ Match priority:
54
57
  2. `dynamic` — the clean (bracket-stripped) prefix must appear in the pathname, and the segment counts must match.
55
58
  3. `catch-all` — the clean prefix must appear in the pathname.
56
59
 
57
- Additional helpers:
58
-
59
- - `getDynamicParams(route, pathname)` — extracts named and spread parameters from a matched dynamic or catch-all route.
60
- - `getSearchParams(url)` — converts `URLSearchParams` to a plain object.
61
- - `setOnReload(cb)` / `reload()` — re-scans routes and fires an optional callback, used during development HMR.
62
-
63
60
  ## `client/`
64
61
 
65
62
  ### Navigation Coordinator (`navigation-coordinator.ts`)
@@ -69,7 +66,7 @@ A singleton browser-side runtime stored on `window.__ECO_PAGES__.navigation`.
69
66
  Access it with:
70
67
 
71
68
  ```ts
72
- import { getEcoNavigationRuntime } from '@ecopages/core/router/client/navigation-coordinator';
69
+ import { getEcoNavigationRuntime } from '@ecopages/core/router/navigation-coordinator';
73
70
  const runtime = getEcoNavigationRuntime();
74
71
  ```
75
72