@ecopages/core 0.2.0-rc.4 → 0.2.0-rc.6

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 (55) hide show
  1. package/README.md +9 -3
  2. package/package.json +7 -4
  3. package/src/build/contracts/content-virtual-modules.d.ts +3 -1
  4. package/src/build/contracts/content-virtual-modules.js +8 -0
  5. package/src/css-imports.d.ts +4 -0
  6. package/src/declarations.d.ts +2 -5
  7. package/src/dev/README.md +2 -0
  8. package/src/dev/transform-server/dev-transform-server.d.ts +6 -0
  9. package/src/dev/transform-server/dev-transform-server.js +16 -5
  10. package/src/eco/README.md +71 -5
  11. package/src/eco/component-identity.d.ts +3 -2
  12. package/src/eco/component-identity.js +5 -2
  13. package/src/eco/discovered-dependencies.d.ts +45 -0
  14. package/src/eco/discovered-dependencies.js +87 -0
  15. package/src/eco/eco.js +2 -0
  16. package/src/eco/eco.types.d.ts +18 -2
  17. package/src/eco/page-dependency-contributions.d.ts +19 -0
  18. package/src/eco/page-dependency-contributions.js +52 -0
  19. package/src/index.browser.d.ts +3 -0
  20. package/src/index.browser.js +3 -0
  21. package/src/index.d.ts +4 -1
  22. package/src/index.js +4 -1
  23. package/src/plugins/README.md +8 -1
  24. package/src/plugins/component-import-discovery.d.ts +19 -0
  25. package/src/plugins/component-import-discovery.js +203 -0
  26. package/src/plugins/eco-component-meta-plugin.d.ts +3 -1
  27. package/src/plugins/eco-component-meta-plugin.js +101 -16
  28. package/src/plugins/processor.d.ts +4 -0
  29. package/src/plugins/processor.js +4 -0
  30. package/src/plugins/tsconfig-import-resolver.d.ts +3 -1
  31. package/src/plugins/tsconfig-import-resolver.js +4 -2
  32. package/src/route-renderer/GRAPH.md +1 -1
  33. package/src/route-renderer/README.md +8 -0
  34. package/src/route-renderer/orchestration/foreign-child/foreign-subtree-execution.service.d.ts +8 -0
  35. package/src/route-renderer/orchestration/foreign-child/foreign-subtree-execution.service.js +9 -1
  36. package/src/route-renderer/orchestration/integration-renderer.d.ts +2 -2
  37. package/src/route-renderer/orchestration/integration-renderer.js +8 -9
  38. package/src/route-renderer/orchestration/ownership-graph/component-graph-collectors.d.ts +1 -4
  39. package/src/route-renderer/orchestration/ownership-graph/component-graph-collectors.js +0 -66
  40. package/src/route-renderer/orchestration/route-pipeline/route-render-orchestrator.js +5 -8
  41. package/src/route-renderer/page-loading/component-dependency-collection.d.ts +6 -2
  42. package/src/route-renderer/page-loading/component-dependency-collection.js +29 -5
  43. package/src/route-renderer/page-loading/file-scoped-dependency-components.d.ts +11 -1
  44. package/src/route-renderer/page-loading/file-scoped-dependency-components.js +40 -6
  45. package/src/route-renderer/page-loading/resolved-page-dependencies.d.ts +1 -2
  46. package/src/route-renderer/page-loading/resolved-page-dependencies.js +8 -7
  47. package/src/services/invalidation/development-invalidation.service.js +10 -3
  48. package/src/services/module-loading/README.md +2 -0
  49. package/src/services/module-loading/collection-server-module-build.service.d.ts +1 -0
  50. package/src/services/module-loading/collection-server-module-build.service.js +5 -2
  51. package/src/services/module-loading/page-module-import.service.d.ts +2 -0
  52. package/src/services/module-loading/page-module-import.service.js +2 -1
  53. package/src/static-site-generator/README.md +3 -1
  54. package/src/types/public-types.d.ts +1 -1
  55. package/src/watchers/project-watcher.js +5 -1
package/src/index.d.ts CHANGED
@@ -3,7 +3,10 @@ export type * from './eco/eco.types.js';
3
3
  export { eco } from './eco/eco.js';
4
4
  export { defineApiHandler, defineGroupHandler, defineGet, definePost, definePut, defineDelete, definePatch, defineOptions, defineHead, json, html, redirect, type GroupHandler, } from './adapters/shared/http/define-api-handler.js';
5
5
  export { createEcoBuildPluginFromSourceTransform, createVitePluginsFromAppSourceTransforms, getAppSourceTransforms, createVitePluginFromSourceTransform, normalizeTransformId, type EcoSourceTransform, type EcoSourceTransformResult, type EcoViteCompatiblePlugin, } from './plugins/source-transform.js';
6
- export { createEcoComponentMetaTransform } from './plugins/eco-component-meta-plugin.js';
6
+ export { attributeComponentIdentity, attributeMdxComponentIdentity, createEcoComponentMetaTransform, } from './plugins/eco-component-meta-plugin.js';
7
7
  export { bindComponentIdentity, getComponentIdentity, type ComponentIdentity } from './eco/component-identity.js';
8
+ export { attachDiscoveredDependencies, registerDiscoveredDependencies, getInferredStylesheets, type DiscoveredDependencies, } from './eco/discovered-dependencies.js';
9
+ export { listFileOwnedDependencyContributions, mergePageDependencies } from './eco/page-dependency-contributions.js';
10
+ export { mergeLayoutDependencies } from './eco/page-layout-normalization.js';
8
11
  export { SchemaError, validateStandardSchema } from './services/validation/validate-standard-schema.js';
9
12
  export { buildIslandHostAttributes, ECO_ISLAND_HOST_ATTRIBUTE, ECO_ISLAND_INTEGRATION_ATTRIBUTE, finalizeIslandComponentRender, isIslandHostElement, mergeIslandHostAttributes, } from './islands/island-host.js';
package/src/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  export { eco } from './eco/eco.js';
2
2
  export { defineApiHandler, defineGroupHandler, defineGet, definePost, definePut, defineDelete, definePatch, defineOptions, defineHead, json, html, redirect, } from './adapters/shared/http/define-api-handler.js';
3
3
  export { createEcoBuildPluginFromSourceTransform, createVitePluginsFromAppSourceTransforms, getAppSourceTransforms, createVitePluginFromSourceTransform, normalizeTransformId, } from './plugins/source-transform.js';
4
- export { createEcoComponentMetaTransform } from './plugins/eco-component-meta-plugin.js';
4
+ export { attributeComponentIdentity, attributeMdxComponentIdentity, createEcoComponentMetaTransform, } from './plugins/eco-component-meta-plugin.js';
5
5
  export { bindComponentIdentity, getComponentIdentity } from './eco/component-identity.js';
6
+ export { attachDiscoveredDependencies, registerDiscoveredDependencies, getInferredStylesheets, } from './eco/discovered-dependencies.js';
7
+ export { listFileOwnedDependencyContributions, mergePageDependencies } from './eco/page-dependency-contributions.js';
8
+ export { mergeLayoutDependencies } from './eco/page-layout-normalization.js';
6
9
  export { SchemaError, validateStandardSchema } from './services/validation/validate-standard-schema.js';
7
10
  export { buildIslandHostAttributes, ECO_ISLAND_HOST_ATTRIBUTE, ECO_ISLAND_INTEGRATION_ATTRIBUTE, finalizeIslandComponentRender, isIslandHostElement, mergeIslandHostAttributes, } from './islands/island-host.js';
@@ -20,7 +20,8 @@ These contracts are responsible for:
20
20
  - `processor.ts`: asset-transformation contract for processors, including generated `@types` package helpers for virtual modules
21
21
  - `runtime-capability.ts`: runtime compatibility declaration types
22
22
  - `source-transform.ts`: bundler-neutral source-transform primitive with Ecopages adapters plus app-level Vite plugin composition helpers
23
- - `eco-component-meta-plugin.ts`: component-identity attribution transform that uses a lexical `eco.` gate, then an Oxc `CallExpression` rewrite
23
+ - `component-import-discovery.ts`: resolves direct local factory exports, named `export { X } from` barrels (imported binding only), relative CSS imports, and aliased CSS imports; records successful named re-export hops as watch files; excludes `export *`, type imports, package imports, and ordinary helpers.
24
+ - `eco-component-meta-plugin.ts`: component-identity attribution transform for `eco.*()` factories and `attributeMdxComponentIdentity` for compiled MDX modules
24
25
 
25
26
  ## Ownership Rules
26
27
 
@@ -28,6 +29,7 @@ These contracts are responsible for:
28
29
  - Processors own asset semantics, cache ownership, and processor-specific watch behavior.
29
30
  - Core owns lifecycle ordering, startup orchestration, and manifest assembly.
30
31
  - The transform wraps native `eco.page()`, `eco.component()`, `eco.layout()`, and `eco.html()` factory options with `bindComponentIdentity()`. Factories retain the resulting `options.identity` on `config`, and runtime consumers read it through `getComponentIdentity()`. Browser, HMR, and server builds use the same source transform, so ownership and dependency diagnostics retain stable file attribution without a loader duplicate.
32
+ - MDX modules compiled by `@ecopages/mdx/core` use `attributeMdxComponentIdentity()` to strip bare CSS imports, attach live component accessors to `config.dependencies`, keep inferred stylesheets on identity, and assign `MDXContent.config = config`. The loader requires `projectRoot` from app config. Markdown code blocks and dynamic imports within functions are distinguished and left unaffected.
31
33
 
32
34
  ## Lifecycle Summary
33
35
 
@@ -35,3 +37,8 @@ These contracts are responsible for:
35
37
  2. Core seals the app-owned build manifest.
36
38
  3. Runtime startup calls runtime-only setup hooks.
37
39
  4. Request-time rendering and development invalidation reuse those finalized contracts.
40
+ 5. `DevelopmentInvalidationService.invalidateServerModules()` calls `Processor.invalidateServerArtifacts()` so processors can discard compiled server artifacts that are not part of the route-module graph.
41
+
42
+ ## Discovered dependency metadata
43
+
44
+ The identity transform passes a deferred Component accessor and resolved stylesheet paths to `bindComponentIdentity`. Factories attach the deferred Component accessor to `config.dependencies`; inferred stylesheets stay keyed by identity for the collector. Import bindings are read during graph traversal, after module initialization. Supported relative and aliased CSS imports are removed from all transformed outputs so the asset pipeline owns CSS delivery. Explicit styles override discovery. Discovery follows named `export { X } from` barrels for the imported binding only. Successful named re-export hops are recorded as watch files on the discovering identity so a barrel retarget invalidates HTML and Page Browser Graph caches without editing the importing Page. It does not follow `export *`, dynamic imports, namespace imports, package Components/CSS, CSS Modules, or custom import attributes. The transform uses the same discovery rules in Bun and Vite/Rolldown. Imported source changes are reparsed through the content-keyed parser cache.
@@ -0,0 +1,19 @@
1
+ export type DiscoveredImports = {
2
+ components: string[];
3
+ stylesheets: string[];
4
+ watchFiles: string[];
5
+ removals: Array<{
6
+ start: number;
7
+ end: number;
8
+ replacement: string;
9
+ }>;
10
+ };
11
+ /**
12
+ * Discovers local Eco Component imports and relative or aliased side-effect CSS.
13
+ *
14
+ * @remarks
15
+ * Named `export { X } from` barrels are followed for the imported binding only.
16
+ * Successful re-export hops are recorded in `watchFiles` so barrel edits invalidate caches.
17
+ * `export *`, dynamic imports, namespace imports, packages, and `.server` modules are not.
18
+ */
19
+ export declare function discoverComponentImports(program: unknown, ownerFile: string, projectRoot: string): DiscoveredImports;
@@ -0,0 +1,203 @@
1
+ import path from 'node:path';
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { cachedParseSync } from '../cache/module-parse-cache.js';
4
+ import { loadTsconfigPathPrefixes, matchesTsconfigPathPrefix, resolveProjectModulePath, } from './tsconfig-import-resolver.js';
5
+ function node(value) {
6
+ return typeof value === 'object' && value !== null ? value : undefined;
7
+ }
8
+ function nodes(value) {
9
+ return Array.isArray(value) ? value.map(node).filter((value) => value !== undefined) : [];
10
+ }
11
+ function identifierName(value) {
12
+ const named = node(value);
13
+ if (typeof named?.name === 'string')
14
+ return named.name;
15
+ if (typeof named?.value === 'string')
16
+ return named.value;
17
+ return undefined;
18
+ }
19
+ function isFactory(value) {
20
+ const call = node(value);
21
+ const callee = node(call?.callee);
22
+ return (call?.type === 'CallExpression' &&
23
+ callee?.type === 'MemberExpression' &&
24
+ node(callee.object)?.name === 'eco' &&
25
+ ['component', 'layout', 'html'].includes(String(node(callee.property)?.name)));
26
+ }
27
+ function isServerSpecifier(source) {
28
+ return /\.server(?:\.[cm]?[jt]sx?)?$/.test(source);
29
+ }
30
+ /**
31
+ * Indexes local `eco.*()` factory exports and named `export { X } from` re-exports.
32
+ *
33
+ * @remarks
34
+ * `export *` is ignored so a barrel cannot pull an entire kit into the graph.
35
+ */
36
+ function indexModuleExports(file, cache) {
37
+ const cached = cache.get(file);
38
+ if (cached)
39
+ return cached;
40
+ const source = readFileSync(file, 'utf8');
41
+ const parsed = cachedParseSync(file, source, { sourceType: 'module' });
42
+ const body = nodes(parsed.program.body);
43
+ const declared = new Set();
44
+ const factories = new Set();
45
+ const namedReexports = new Map();
46
+ for (const statement of body) {
47
+ const declaration = statement.type === 'ExportNamedDeclaration' ? node(statement.declaration) : statement;
48
+ for (const variable of nodes(declaration?.declarations)) {
49
+ const name = identifierName(variable.id);
50
+ if (typeof name === 'string' && isFactory(variable.init)) {
51
+ declared.add(name);
52
+ if (statement.type === 'ExportNamedDeclaration')
53
+ factories.add(name);
54
+ }
55
+ }
56
+ if (statement.type === 'ExportDefaultDeclaration' && isFactory(statement.declaration))
57
+ factories.add('default');
58
+ }
59
+ for (const statement of body) {
60
+ if (statement.type === 'ExportDefaultDeclaration' &&
61
+ declared.has(String(identifierName(statement.declaration)))) {
62
+ factories.add('default');
63
+ }
64
+ if (statement.type !== 'ExportNamedDeclaration' || statement.exportKind === 'type')
65
+ continue;
66
+ const from = node(statement.source)?.value;
67
+ if (typeof from === 'string') {
68
+ for (const specifier of nodes(statement.specifiers)) {
69
+ if (specifier.exportKind === 'type')
70
+ continue;
71
+ const exported = identifierName(specifier.exported);
72
+ const imported = identifierName(specifier.local) ?? exported;
73
+ if (exported && imported)
74
+ namedReexports.set(exported, { source: from, imported });
75
+ }
76
+ continue;
77
+ }
78
+ for (const specifier of nodes(statement.specifiers)) {
79
+ if (specifier.exportKind !== 'type' && declared.has(String(identifierName(specifier.local)))) {
80
+ const exported = identifierName(specifier.exported);
81
+ if (exported)
82
+ factories.add(exported);
83
+ }
84
+ }
85
+ }
86
+ const indexed = { factories, namedReexports };
87
+ cache.set(file, indexed);
88
+ return indexed;
89
+ }
90
+ /**
91
+ * True when `exportName` is an `eco.*()` factory, including through named barrel re-exports.
92
+ *
93
+ * @remarks
94
+ * Completed answers are memoized on `resolved`. `path` is only the current walk, so a
95
+ * second alias of the same Component is not treated as a cycle miss.
96
+ */
97
+ function isFactoryExport(file, exportName, projectRoot, pathPrefixes, state) {
98
+ const visitKey = `${file}\0${exportName}`;
99
+ const known = state.resolved.get(visitKey);
100
+ if (known !== undefined)
101
+ return known;
102
+ if (state.path.has(visitKey))
103
+ return false;
104
+ state.path.add(visitKey);
105
+ let result = false;
106
+ if (!file.includes(`${path.sep}node_modules${path.sep}`) && !isServerSpecifier(file)) {
107
+ const { factories, namedReexports } = indexModuleExports(file, state.moduleIndex);
108
+ if (factories.has(exportName)) {
109
+ result = true;
110
+ }
111
+ else {
112
+ const reexport = namedReexports.get(exportName);
113
+ if (reexport &&
114
+ !isServerSpecifier(reexport.source) &&
115
+ (reexport.source.startsWith('.') || matchesTsconfigPathPrefix(reexport.source, pathPrefixes))) {
116
+ const resolved = resolveProjectModulePath(projectRoot, file, reexport.source, { preserveBarrel: true });
117
+ if (!resolved) {
118
+ throw new Error(`[ecopages] Cannot resolve import ${JSON.stringify(reexport.source)} from ${file}`);
119
+ }
120
+ if (/\.[jt]sx?$/.test(resolved) && !resolved.includes(`${path.sep}node_modules${path.sep}`)) {
121
+ result = isFactoryExport(resolved, reexport.imported, projectRoot, pathPrefixes, state);
122
+ if (result)
123
+ state.watchFiles.add(file);
124
+ }
125
+ }
126
+ }
127
+ }
128
+ state.path.delete(visitKey);
129
+ state.resolved.set(visitKey, result);
130
+ return result;
131
+ }
132
+ function isCssImport(source) {
133
+ return source.endsWith('.css') && !source.endsWith('.module.css');
134
+ }
135
+ /**
136
+ * Discovers local Eco Component imports and relative or aliased side-effect CSS.
137
+ *
138
+ * @remarks
139
+ * Named `export { X } from` barrels are followed for the imported binding only.
140
+ * Successful re-export hops are recorded in `watchFiles` so barrel edits invalidate caches.
141
+ * `export *`, dynamic imports, namespace imports, packages, and `.server` modules are not.
142
+ */
143
+ export function discoverComponentImports(program, ownerFile, projectRoot) {
144
+ const result = { components: [], stylesheets: [], watchFiles: [], removals: [] };
145
+ const pathPrefixes = loadTsconfigPathPrefixes(projectRoot);
146
+ const factoryState = {
147
+ moduleIndex: new Map(),
148
+ resolved: new Map(),
149
+ watchFiles: new Set(),
150
+ };
151
+ for (const statement of nodes(node(program)?.body)) {
152
+ if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
153
+ continue;
154
+ if (nodes(statement.attributes).length || nodes(statement.assertions).length)
155
+ continue;
156
+ const source = node(statement.source)?.value;
157
+ if (typeof source !== 'string')
158
+ continue;
159
+ const specifiers = nodes(statement.specifiers);
160
+ if (isCssImport(source) && !specifiers.length) {
161
+ const isRelative = source.startsWith('.');
162
+ const isAlias = matchesTsconfigPathPrefix(source, pathPrefixes);
163
+ if (isRelative || isAlias) {
164
+ const resolved = isRelative
165
+ ? existsSync(path.resolve(path.dirname(ownerFile), source))
166
+ ? realpathSync(path.resolve(path.dirname(ownerFile), source))
167
+ : undefined
168
+ : resolveProjectModulePath(projectRoot, ownerFile, source, { preserveBarrel: true });
169
+ if (!resolved || !existsSync(resolved)) {
170
+ throw new Error(`[ecopages] Cannot resolve stylesheet import ${JSON.stringify(source)} from ${ownerFile}`);
171
+ }
172
+ result.stylesheets.push(resolved);
173
+ result.removals.push({ start: statement.start, end: statement.end, replacement: '' });
174
+ continue;
175
+ }
176
+ }
177
+ const values = specifiers.filter((entry) => entry.importKind !== 'type' &&
178
+ (entry.type === 'ImportSpecifier' || entry.type === 'ImportDefaultSpecifier'));
179
+ if (!values.length || (!source.startsWith('.') && !matchesTsconfigPathPrefix(source, pathPrefixes)))
180
+ continue;
181
+ if (isServerSpecifier(source))
182
+ continue;
183
+ const resolved = resolveProjectModulePath(projectRoot, ownerFile, source, { preserveBarrel: true });
184
+ if (!resolved)
185
+ throw new Error(`[ecopages] Cannot resolve import ${JSON.stringify(source)} from ${ownerFile}`);
186
+ if (!/\.[jt]sx?$/.test(resolved) || resolved.includes(`${path.sep}node_modules${path.sep}`))
187
+ continue;
188
+ for (const specifier of values) {
189
+ const imported = specifier.type === 'ImportDefaultSpecifier' ? 'default' : identifierName(specifier.imported);
190
+ const local = identifierName(specifier.local);
191
+ if (typeof local === 'string' &&
192
+ imported &&
193
+ isFactoryExport(resolved, imported, projectRoot, pathPrefixes, {
194
+ ...factoryState,
195
+ path: new Set(),
196
+ })) {
197
+ result.components.push(local);
198
+ }
199
+ }
200
+ }
201
+ result.watchFiles.push(...factoryState.watchFiles);
202
+ return result;
203
+ }
@@ -5,7 +5,9 @@ export interface EcoComponentDirPluginOptions {
5
5
  config: EcoPagesAppConfig;
6
6
  }
7
7
  /** Attributes real `eco.*()` factory calls with canonical component identity. */
8
- export declare function attributeComponentIdentity(contents: string, filePath: string, integration: string): string;
8
+ export declare function attributeComponentIdentity(contents: string, filePath: string, integration: string, projectRoot?: string): string;
9
+ /** Attributes compiled MDX module with canonical component identity and discovered dependencies. */
10
+ export declare function attributeMdxComponentIdentity(contents: string, filePath: string, integration: string, projectRoot: string): string;
9
11
  export declare function createEcoComponentMetaTransform(options: EcoComponentDirPluginOptions): EcoSourceTransform;
10
12
  export declare function createEcoComponentMetaVitePlugin(options: EcoComponentDirPluginOptions): EcoViteCompatiblePlugin;
11
13
  export declare function createEcoComponentMetaPlugin(options: EcoComponentDirPluginOptions): EcoBuildPlugin;
@@ -2,6 +2,7 @@ import { prependJsxImportSourceIfMissing } from './jsx-import-source.utils.js';
2
2
  import { createEcoBuildPluginFromSourceTransform, createVitePluginFromSourceTransform } from './source-transform.js';
3
3
  import { cachedParseSync } from '../cache/module-parse-cache.js';
4
4
  import { rapidhash } from '../utils/hash.js';
5
+ import { discoverComponentImports } from './component-import-discovery.js';
5
6
  function integrationForFile(filePath, config) {
6
7
  const candidates = config.integrations
7
8
  .flatMap((integration) => integration.extensions.map((extension) => [extension, integration]))
@@ -12,6 +13,12 @@ function integrationForFile(filePath, config) {
12
13
  function isAstNode(value) {
13
14
  return typeof value === 'object' && value !== null;
14
15
  }
16
+ function node(value) {
17
+ return typeof value === 'object' && value !== null ? value : undefined;
18
+ }
19
+ function nodes(value) {
20
+ return Array.isArray(value) ? value.map(node).filter((value) => value !== undefined) : [];
21
+ }
15
22
  function isEcoFactoryCall(node) {
16
23
  if (node.type !== 'CallExpression' || !isAstNode(node.callee))
17
24
  return false;
@@ -41,36 +48,52 @@ function walkAst(node, visit) {
41
48
  for (const value of Object.values(node))
42
49
  walkAst(value, visit);
43
50
  }
44
- function addIdentityBindingImport(contents, program) {
51
+ function addNamedImport(contents, program, sourceModule, importedName) {
52
+ const importedNames = typeof importedName === 'string' ? [importedName] : [...importedName];
45
53
  const imports = program.body.filter((node) => isAstNode(node) &&
46
54
  node.type === 'ImportDeclaration' &&
47
55
  isAstNode(node.source) &&
48
- node.source.value === '@ecopages/core');
56
+ node.source.value === sourceModule);
49
57
  const valueImport = imports.find((node) => node.importKind !== 'type');
50
58
  if (!valueImport)
51
- return `import { bindComponentIdentity } from '@ecopages/core';\n${contents}`;
59
+ return `import { ${importedNames.join(', ')} } from '${sourceModule}';\n${contents}`;
52
60
  const specifiers = Array.isArray(valueImport.specifiers) ? valueImport.specifiers.filter(isAstNode) : [];
53
- if (specifiers.some((specifier) => specifier.type === 'ImportSpecifier' &&
54
- isAstNode(specifier.imported) &&
55
- specifier.imported.name === 'bindComponentIdentity')) {
61
+ const existingNames = new Set(specifiers.flatMap((specifier) => {
62
+ if (specifier.type !== 'ImportSpecifier' || !isAstNode(specifier.imported))
63
+ return [];
64
+ const name = specifier.imported.name ?? specifier.imported.value;
65
+ return typeof name === 'string' ? [name] : [];
66
+ }));
67
+ const missing = importedNames.filter((name) => !existingNames.has(name));
68
+ if (missing.length === 0) {
56
69
  return contents;
57
70
  }
58
71
  const namedSpecifiers = specifiers.filter((specifier) => specifier.type === 'ImportSpecifier');
59
72
  if (namedSpecifiers.length > 0) {
60
73
  const lastSpecifier = namedSpecifiers[namedSpecifiers.length - 1];
61
- return `${contents.slice(0, lastSpecifier.end)}, bindComponentIdentity${contents.slice(lastSpecifier.end)}`;
74
+ return `${contents.slice(0, lastSpecifier.end)}, ${missing.join(', ')}${contents.slice(lastSpecifier.end)}`;
62
75
  }
63
76
  if (specifiers.some((specifier) => specifier.type === 'ImportNamespaceSpecifier')) {
64
- return `import { bindComponentIdentity } from '@ecopages/core';\n${contents}`;
77
+ return `import { ${missing.join(', ')} } from '${sourceModule}';\n${contents}`;
65
78
  }
66
79
  const defaultSpecifier = specifiers.find((specifier) => specifier.type === 'ImportDefaultSpecifier');
67
80
  if (defaultSpecifier) {
68
- return `${contents.slice(0, defaultSpecifier.end)}, { bindComponentIdentity }${contents.slice(defaultSpecifier.end)}`;
81
+ return `${contents.slice(0, defaultSpecifier.end)}, { ${missing.join(', ')} }${contents.slice(defaultSpecifier.end)}`;
82
+ }
83
+ return `import { ${missing.join(', ')} } from '${sourceModule}';\n${contents}`;
84
+ }
85
+ function addIdentityBindingImport(contents, program) {
86
+ return addNamedImport(contents, program, '@ecopages/core', 'bindComponentIdentity');
87
+ }
88
+ function serializeDiscoveryArgument(discovered) {
89
+ if (!discovered || (discovered.components.length === 0 && discovered.stylesheets.length === 0)) {
90
+ return '';
69
91
  }
70
- return `import { bindComponentIdentity } from '@ecopages/core';\n${contents}`;
92
+ const watchFiles = discovered.watchFiles.length > 0 ? `, watchFiles: ${JSON.stringify(discovered.watchFiles)}` : '';
93
+ return `, { components: () => [${discovered.components.join(', ')}], stylesheets: ${JSON.stringify(discovered.stylesheets)}${watchFiles} }`;
71
94
  }
72
95
  /** Attributes real `eco.*()` factory calls with canonical component identity. */
73
- export function attributeComponentIdentity(contents, filePath, integration) {
96
+ export function attributeComponentIdentity(contents, filePath, integration, projectRoot) {
74
97
  if (!contents.includes('eco.'))
75
98
  return contents;
76
99
  let program;
@@ -82,6 +105,13 @@ export function attributeComponentIdentity(contents, filePath, integration) {
82
105
  }
83
106
  const identityLiteral = `{ id: ${JSON.stringify(rapidhash(filePath).toString(36))}, file: ${JSON.stringify(filePath)}, integration: ${JSON.stringify(integration)} }`;
84
107
  const edits = [];
108
+ let hasFactory = false;
109
+ walkAst(program, (node) => {
110
+ if (isEcoFactoryCall(node))
111
+ hasFactory = true;
112
+ });
113
+ const discovered = hasFactory && projectRoot ? discoverComponentImports(program, filePath, projectRoot) : undefined;
114
+ const discoveryArgument = serializeDiscoveryArgument(discovered);
85
115
  walkAst(program, (node) => {
86
116
  if (!isEcoFactoryCall(node) || !Array.isArray(node.arguments))
87
117
  return;
@@ -93,16 +123,73 @@ export function attributeComponentIdentity(contents, filePath, integration) {
93
123
  edits.push({
94
124
  start: firstArgument.start,
95
125
  end: firstArgument.end,
96
- replacement: `bindComponentIdentity(${identityLiteral}, ${contents.slice(firstArgument.start, firstArgument.end)})`,
126
+ replacement: `bindComponentIdentity(${identityLiteral}, ${contents.slice(firstArgument.start, firstArgument.end)}${discoveryArgument})`,
97
127
  });
98
128
  });
99
129
  if (edits.length === 0)
100
130
  return contents;
131
+ edits.push(...(discovered?.removals ?? []));
101
132
  let transformed = contents;
102
133
  for (const edit of edits.sort((left, right) => right.start - left.start)) {
103
134
  transformed = `${transformed.slice(0, edit.start)}${edit.replacement}${transformed.slice(edit.end)}`;
104
135
  }
105
- return addIdentityBindingImport(transformed, program);
136
+ return addIdentityBindingImport(transformed, cachedParseSync(filePath, transformed, { sourceType: 'module' }).program);
137
+ }
138
+ /** Attributes compiled MDX module with canonical component identity and discovered dependencies. */
139
+ export function attributeMdxComponentIdentity(contents, filePath, integration, projectRoot) {
140
+ if (!projectRoot) {
141
+ throw new Error(`[ecopages] Cannot process MDX dependencies for "${filePath}": projectRoot is required.`);
142
+ }
143
+ let program;
144
+ try {
145
+ program = cachedParseSync(filePath, contents, { lang: 'jsx', sourceType: 'module' })
146
+ .program;
147
+ }
148
+ catch {
149
+ return contents;
150
+ }
151
+ let configDeclarator;
152
+ for (const statement of nodes(program?.body)) {
153
+ if (statement.type === 'ExportNamedDeclaration' && statement.declaration) {
154
+ const decl = node(statement.declaration);
155
+ if (decl?.type === 'VariableDeclaration') {
156
+ for (const declarator of nodes(decl.declarations)) {
157
+ if (node(declarator.id)?.name === 'config') {
158
+ configDeclarator = declarator;
159
+ break;
160
+ }
161
+ }
162
+ }
163
+ }
164
+ }
165
+ if (configDeclarator && isIdentityBinding(configDeclarator.init)) {
166
+ return contents;
167
+ }
168
+ const discovered = discoverComponentImports(program, filePath, projectRoot);
169
+ const identityLiteral = `{ id: ${JSON.stringify(rapidhash(filePath).toString(36))}, file: ${JSON.stringify(filePath)}, integration: ${JSON.stringify(integration)} }`;
170
+ const discoveryArgument = serializeDiscoveryArgument(discovered);
171
+ const edits = [...discovered.removals];
172
+ if (configDeclarator && isAstNode(configDeclarator.init)) {
173
+ const init = configDeclarator.init;
174
+ if (typeof init.start === 'number' && typeof init.end === 'number') {
175
+ edits.push({
176
+ start: init.start,
177
+ end: init.end,
178
+ replacement: `bindComponentIdentity(${identityLiteral}, ${contents.slice(init.start, init.end)}${discoveryArgument})`,
179
+ });
180
+ }
181
+ }
182
+ let appended = '';
183
+ if (!configDeclarator) {
184
+ appended += `\nexport const config = bindComponentIdentity(${identityLiteral}, {}${discoveryArgument});\n`;
185
+ }
186
+ appended += `attachDiscoveredDependencies(config);\nif (typeof MDXContent === 'function') MDXContent.config = config;\n`;
187
+ let transformed = contents;
188
+ for (const edit of edits.sort((left, right) => right.start - left.start)) {
189
+ transformed = `${transformed.slice(0, edit.start)}${edit.replacement}${transformed.slice(edit.end)}`;
190
+ }
191
+ transformed = `${transformed}\n${appended}`;
192
+ return addNamedImport(transformed, cachedParseSync(filePath, transformed, { lang: 'jsx', sourceType: 'module' }).program, '@ecopages/core', ['bindComponentIdentity', 'attachDiscoveredDependencies']);
106
193
  }
107
194
  export function createEcoComponentMetaTransform(options) {
108
195
  const extensions = options.config.integrations
@@ -115,14 +202,12 @@ export function createEcoComponentMetaTransform(options) {
115
202
  enforce: 'pre',
116
203
  filter,
117
204
  transform(code, id) {
118
- if (id.endsWith('.mdx'))
119
- return { code };
120
205
  const integration = integrationForFile(id, options.config);
121
206
  if (!integration) {
122
207
  return { code };
123
208
  }
124
209
  return {
125
- code: prependJsxImportSourceIfMissing(attributeComponentIdentity(code, id, integration.name), integration.jsxImportSource),
210
+ code: prependJsxImportSourceIfMissing(attributeComponentIdentity(code, id, integration.name, options.config.rootDir), integration.jsxImportSource),
126
211
  };
127
212
  },
128
213
  };
@@ -147,6 +147,10 @@ export declare abstract class Processor<TOptions = Record<string, unknown>> {
147
147
  didChange(): boolean;
148
148
  abstract setup(): Promise<void>;
149
149
  abstract process(input: unknown, filePath?: string): Promise<unknown>;
150
+ /**
151
+ * Discards cached server build artifacts when server modules are invalidated.
152
+ */
153
+ invalidateServerArtifacts(): void;
150
154
  /**
151
155
  * Releases runtime resources owned by the processor.
152
156
  *
@@ -108,6 +108,10 @@ export class Processor {
108
108
  didChange() {
109
109
  return false;
110
110
  }
111
+ /**
112
+ * Discards cached server build artifacts when server modules are invalidated.
113
+ */
114
+ invalidateServerArtifacts() { }
111
115
  /**
112
116
  * Releases runtime resources owned by the processor.
113
117
  *
@@ -30,7 +30,9 @@ export declare function resolveRelativeModulePath(fromFile: string, specifier: s
30
30
  /**
31
31
  * Resolves a relative or tsconfig path alias import (via oxc-resolver).
32
32
  */
33
- export declare function resolveProjectModulePath(projectRoot: string, fromFile: string, specifier: string): string | undefined;
33
+ export declare function resolveProjectModulePath(projectRoot: string, fromFile: string, specifier: string, options?: {
34
+ preserveBarrel?: boolean;
35
+ }): string | undefined;
34
36
  /**
35
37
  * Resolves a TS path alias import using the app's tsconfig `paths` (via oxc-resolver).
36
38
  */
@@ -232,7 +232,7 @@ export function resolveRelativeModulePath(fromFile, specifier) {
232
232
  /**
233
233
  * Resolves a relative or tsconfig path alias import (via oxc-resolver).
234
234
  */
235
- export function resolveProjectModulePath(projectRoot, fromFile, specifier) {
235
+ export function resolveProjectModulePath(projectRoot, fromFile, specifier, options) {
236
236
  const prefixes = loadTsconfigPathPrefixes(projectRoot);
237
237
  const isRelative = specifier.startsWith('.');
238
238
  const isPathAlias = matchesTsconfigPathPrefix(specifier, prefixes);
@@ -243,7 +243,9 @@ export function resolveProjectModulePath(projectRoot, fromFile, specifier) {
243
243
  if (resolver) {
244
244
  const result = resolver.sync(path.dirname(fromFile), specifier);
245
245
  if (result.path) {
246
- return resolveAliasedBarrelTarget(realpathSync(result.path));
246
+ return options?.preserveBarrel
247
+ ? realpathSync(result.path)
248
+ : resolveAliasedBarrelTarget(realpathSync(result.path));
247
249
  }
248
250
  }
249
251
  if (isRelative) {
@@ -43,7 +43,7 @@ flowchart TD
43
43
  D --> E[resolvePageData]
44
44
  E --> F[ownershipValidationService.validate + throwIfOwnershipInvalid]
45
45
  F --> G[resolveDependencies + buildPageBrowserGraph]
46
- G --> H[collect injector and eager SSR lazy assets]
46
+ G --> H[collect lazy injector assets]
47
47
  H --> I[build pagePackage and prepared render options]
48
48
  I --> J[IntegrationRenderer.render via document shell]
49
49
  J --> K[capture rendered body as html]
@@ -106,6 +106,8 @@ Mixed-integration component configs must declare every possible foreign child in
106
106
 
107
107
  At runtime, renderers still discover actual foreign children through the active component render context.
108
108
 
109
+ Component renders always execute under a render context that names the rendering integration (`executeComponentRender` installs one when none is active). This lets ownership guards such as the content-processor's `getComponent()` detect a foreign render lane even when no foreign-child runtime is installed.
110
+
109
111
  ## Foreign Subtree Contract
110
112
 
111
113
  `renderComponentWithForeignChildren()` is the renderer entrypoint for mixed-integration trees. It is responsible for:
@@ -137,3 +139,9 @@ Not every integration needs queue-based handoff.
137
139
 
138
140
  - Different integrations still own different foreign-child runtime strategies, which is intentional where child transport or hydration behavior differs.
139
141
  - `integration-renderer.ts` remains the largest integration hook surface even after document-shell extraction.
142
+
143
+ ## Discovered Dependencies and loading policy
144
+
145
+ Dependency collection reads declared `config.dependencies` plus identity-keyed inferred stylesheets, including deferred direct-import Component references. Traversal visits each config once; multiple Components in one file remain distinct. It collects explicit styles before inferred styles across the graph, suppressing inferred duplicates by resolved path. Page dependency results may include multiple file-owned contributions so relative assets keep the file that declared them. Source watch paths include discovered Components, their stylesheet files, and named barrel hops recorded during discovery.
146
+
147
+ Lit preloads scripts marked `ssr: true` before server rendering, while the existing lazy-trigger manifest controls browser loading. The route pipeline does not emit a second eager script for these entries. Integration renderers refresh their resolved global assets after runtime activation so late setup cannot omit hydration support.
@@ -126,6 +126,14 @@ export declare class ForeignSubtreeExecutionService {
126
126
  assets: ProcessedAsset[];
127
127
  html: string;
128
128
  }>;
129
+ /**
130
+ * Runs one component render under the current integration's render context and
131
+ * resolves any queued foreign subtrees captured during that render.
132
+ *
133
+ * @remarks
134
+ * Component renders always execute under a context that names the rendering
135
+ * integration, even when no foreign-child runtime is installed.
136
+ */
129
137
  executeComponentRender(options: ForeignSubtreeExecutionRenderOptions): Promise<ComponentRenderResult>;
130
138
  private getRendererCache;
131
139
  private withRendererCache;
@@ -221,6 +221,14 @@ export class ForeignSubtreeExecutionService {
221
221
  html: resolvedHtml,
222
222
  };
223
223
  }
224
+ /**
225
+ * Runs one component render under the current integration's render context and
226
+ * resolves any queued foreign subtrees captured during that render.
227
+ *
228
+ * @remarks
229
+ * Component renders always execute under a context that names the rendering
230
+ * integration, even when no foreign-child runtime is installed.
231
+ */
224
232
  async executeComponentRender(options) {
225
233
  const rendererCache = this.getRendererCache(options.input.integrationContext) ??
226
234
  new Map();
@@ -237,7 +245,7 @@ export class ForeignSubtreeExecutionService {
237
245
  this.requiresForeignChildRuntime(options.input);
238
246
  const activeRenderContext = getComponentRenderContext();
239
247
  if (!hasForeignChildren) {
240
- if (!activeRenderContext || activeRenderContext.currentIntegration === options.currentIntegrationName) {
248
+ if (activeRenderContext && activeRenderContext.currentIntegration === options.currentIntegrationName) {
241
249
  return options.normalizeComponentRenderOutput(await options.renderComponent(options.input));
242
250
  }
243
251
  const sameIntegrationExecution = await runWithComponentRenderContext({
@@ -4,7 +4,7 @@
4
4
  * @module
5
5
  */
6
6
  import type { EcoPagesAppConfig, IHmrManager } from '../../types/internal-types.js';
7
- import type { ComponentRenderInput, ComponentRenderResult, EcoComponent, EcoComponentDependencies, EcoPageFile, EcoPagesElement, HtmlTemplateProps, IntegrationRendererRenderOptions, PageBrowserGraphContribution, PageBrowserGraphContributionContext, PageBrowserGraphResult, PageMetadataProps, RouteRendererBody, RouteRendererOptions, RouteRenderResult } from '../../types/public-types.js';
7
+ import type { ComponentRenderInput, ComponentRenderResult, EcoComponent, EcoPageFile, EcoPagesElement, HtmlTemplateProps, IntegrationRendererRenderOptions, PageBrowserGraphContribution, PageBrowserGraphContributionContext, PageBrowserGraphResult, PageMetadataProps, RouteRendererBody, RouteRendererOptions, RouteRenderResult } from '../../types/public-types.js';
8
8
  import { type AssetProcessingService, type ProcessedAsset } from '../../services/assets/asset-processing-service/index.js';
9
9
  import { HtmlTransformerService } from '../../services/html/html-transformer.service.js';
10
10
  import type { HtmlDocumentContribution } from '../../services/html/html-transformer.service.js';
@@ -281,7 +281,7 @@ export declare abstract class IntegrationRenderer<C = EcoPagesElement> {
281
281
  protected resolveRouteRenderInputs(routeOptions: RouteRendererOptions): Promise<RouteRenderOrchestratorResolvedInputs>;
282
282
  protected buildPageBrowserGraphContributionContext(routeFile: string, routeOptions?: Pick<RouteRendererOptions, 'params' | 'query'>): Promise<PageBrowserGraphContributionContext>;
283
283
  protected resolvePageDependencies(context: PageBrowserGraphContributionContext): Promise<ResolvedPageDependencies | undefined>;
284
- protected resolvePageBrowserGraphContributionFromDependencies(dependencies: EcoComponentDependencies, ownerFile: string): Promise<PageBrowserGraphContribution | undefined>;
284
+ protected resolvePageBrowserGraphContributionFromComponents(components: ReadonlyArray<EcoComponent | Partial<EcoComponent>>, ownerFile: string): Promise<PageBrowserGraphContribution | undefined>;
285
285
  protected resolveRouteDependencies(input: {
286
286
  components: (EcoComponent | Partial<EcoComponent>)[];
287
287
  }): Promise<{