@ecopages/core 0.2.0-rc.4 → 0.2.0-rc.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.
- package/README.md +5 -2
- package/package.json +7 -4
- package/src/build/contracts/content-virtual-modules.d.ts +3 -1
- package/src/build/contracts/content-virtual-modules.js +8 -0
- package/src/css-imports.d.ts +4 -0
- package/src/declarations.d.ts +2 -5
- package/src/dev/README.md +2 -0
- package/src/dev/transform-server/dev-transform-server.d.ts +6 -0
- package/src/dev/transform-server/dev-transform-server.js +16 -5
- package/src/eco/README.md +71 -5
- package/src/eco/component-identity.d.ts +3 -2
- package/src/eco/component-identity.js +5 -2
- package/src/eco/discovered-dependencies.d.ts +45 -0
- package/src/eco/discovered-dependencies.js +87 -0
- package/src/eco/eco.js +2 -0
- package/src/eco/eco.types.d.ts +18 -2
- package/src/eco/page-dependency-contributions.d.ts +19 -0
- package/src/eco/page-dependency-contributions.js +52 -0
- package/src/index.browser.d.ts +3 -0
- package/src/index.browser.js +3 -0
- package/src/index.d.ts +4 -1
- package/src/index.js +4 -1
- package/src/plugins/README.md +7 -1
- package/src/plugins/component-import-discovery.d.ts +19 -0
- package/src/plugins/component-import-discovery.js +203 -0
- package/src/plugins/eco-component-meta-plugin.d.ts +3 -1
- package/src/plugins/eco-component-meta-plugin.js +101 -16
- package/src/plugins/tsconfig-import-resolver.d.ts +3 -1
- package/src/plugins/tsconfig-import-resolver.js +4 -2
- package/src/route-renderer/GRAPH.md +1 -1
- package/src/route-renderer/README.md +8 -0
- package/src/route-renderer/orchestration/foreign-child/foreign-subtree-execution.service.d.ts +8 -0
- package/src/route-renderer/orchestration/foreign-child/foreign-subtree-execution.service.js +9 -1
- package/src/route-renderer/orchestration/integration-renderer.d.ts +2 -2
- package/src/route-renderer/orchestration/integration-renderer.js +8 -9
- package/src/route-renderer/orchestration/ownership-graph/component-graph-collectors.d.ts +1 -4
- package/src/route-renderer/orchestration/ownership-graph/component-graph-collectors.js +0 -66
- package/src/route-renderer/orchestration/route-pipeline/route-render-orchestrator.js +5 -8
- package/src/route-renderer/page-loading/component-dependency-collection.d.ts +6 -2
- package/src/route-renderer/page-loading/component-dependency-collection.js +29 -5
- package/src/route-renderer/page-loading/file-scoped-dependency-components.d.ts +11 -1
- package/src/route-renderer/page-loading/file-scoped-dependency-components.js +40 -6
- package/src/route-renderer/page-loading/resolved-page-dependencies.d.ts +1 -2
- package/src/route-renderer/page-loading/resolved-page-dependencies.js +8 -7
- package/src/services/module-loading/page-module-import.service.d.ts +2 -0
- package/src/services/module-loading/page-module-import.service.js +2 -1
- package/src/static-site-generator/README.md +3 -1
- package/src/types/public-types.d.ts +1 -1
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';
|
package/src/plugins/README.md
CHANGED
|
@@ -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
|
-
- `
|
|
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,7 @@ 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
|
+
|
|
41
|
+
## Discovered dependency metadata
|
|
42
|
+
|
|
43
|
+
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
|
|
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 ===
|
|
56
|
+
node.source.value === sourceModule);
|
|
49
57
|
const valueImport = imports.find((node) => node.importKind !== 'type');
|
|
50
58
|
if (!valueImport)
|
|
51
|
-
return `import {
|
|
59
|
+
return `import { ${importedNames.join(', ')} } from '${sourceModule}';\n${contents}`;
|
|
52
60
|
const specifiers = Array.isArray(valueImport.specifiers) ? valueImport.specifiers.filter(isAstNode) : [];
|
|
53
|
-
|
|
54
|
-
isAstNode(specifier.imported)
|
|
55
|
-
|
|
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)},
|
|
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 {
|
|
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)}, {
|
|
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
|
-
|
|
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
|
};
|
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
package/src/route-renderer/orchestration/foreign-child/foreign-subtree-execution.service.d.ts
CHANGED
|
@@ -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 (
|
|
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,
|
|
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
|
|
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<{
|
|
@@ -15,7 +15,7 @@ import { RouteRenderOrchestrator, } from './route-pipeline/route-render-orchestr
|
|
|
15
15
|
import { createIntegrationRouteRenderAdapter } from './route-pipeline/integration-route-render-adapter.js';
|
|
16
16
|
import { createPageDependencyInstanceKey } from './page-browser-graph/route-instance-key.js';
|
|
17
17
|
import { mergePageBrowserGraphContributions } from './page-browser-graph/page-browser-graph-contribution.merge.js';
|
|
18
|
-
import { collectDependencyWatchPaths
|
|
18
|
+
import { collectDependencyWatchPaths } from '../page-loading/file-scoped-dependency-components.js';
|
|
19
19
|
import { resolvePageDependenciesFromContext, } from '../page-loading/resolved-page-dependencies.js';
|
|
20
20
|
import { createGroupedGraphBuildPlanKey, } from './page-browser-graph/grouped-graph-build-plan.js';
|
|
21
21
|
import { normalizeUnresolvedMarkerArtifactHtml } from './route-pipeline/marker-artifact.utils.js';
|
|
@@ -68,6 +68,10 @@ export class IntegrationRenderer {
|
|
|
68
68
|
integrationName: this.name,
|
|
69
69
|
runtimeOrigin: this.runtimeOrigin,
|
|
70
70
|
});
|
|
71
|
+
const integration = this.appConfig.integrations?.find((entry) => entry.name === this.name);
|
|
72
|
+
if (integration?.getResolvedIntegrationDependencies) {
|
|
73
|
+
this.resolvedIntegrationDependencies = integration.getResolvedIntegrationDependencies();
|
|
74
|
+
}
|
|
71
75
|
}
|
|
72
76
|
/**
|
|
73
77
|
* Prebuilds the production Page Browser Graph for one route file and optional params.
|
|
@@ -586,19 +590,14 @@ export class IntegrationRenderer {
|
|
|
586
590
|
};
|
|
587
591
|
}
|
|
588
592
|
async resolvePageDependencies(context) {
|
|
589
|
-
return resolvePageDependenciesFromContext(context, this.name, (
|
|
593
|
+
return resolvePageDependenciesFromContext(context, this.name, (components, ownerFile) => this.resolvePageBrowserGraphContributionFromComponents(components, ownerFile));
|
|
590
594
|
}
|
|
591
|
-
async
|
|
592
|
-
const components = collectFileScopedDependencyComponents({
|
|
593
|
-
ownerFile,
|
|
594
|
-
integrationName: this.name,
|
|
595
|
-
dependencies,
|
|
596
|
-
});
|
|
595
|
+
async resolvePageBrowserGraphContributionFromComponents(components, ownerFile) {
|
|
597
596
|
if (components.length === 0) {
|
|
598
597
|
return undefined;
|
|
599
598
|
}
|
|
600
599
|
return {
|
|
601
|
-
assets: await this.processComponentDependencies(components),
|
|
600
|
+
assets: await this.processComponentDependencies([...components]),
|
|
602
601
|
watchPaths: collectDependencyWatchPaths(ownerFile, components),
|
|
603
602
|
};
|
|
604
603
|
}
|