@ecopages/core 0.2.0-beta.12 → 0.2.0-beta.14

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 (54) hide show
  1. package/README.md +1 -1
  2. package/package.json +26 -2
  3. package/src/adapters/abstract/application-adapter.d.ts +27 -3
  4. package/src/adapters/abstract/application-adapter.js +28 -0
  5. package/src/adapters/abstract/server-adapter.d.ts +3 -2
  6. package/src/adapters/bun/create-app.d.ts +1 -1
  7. package/src/adapters/bun/create-app.js +23 -6
  8. package/src/adapters/bun/server-adapter.d.ts +9 -2
  9. package/src/adapters/bun/server-adapter.js +28 -4
  10. package/src/adapters/create-app.d.ts +1 -12
  11. package/src/adapters/create-app.js +0 -58
  12. package/src/adapters/index.d.ts +1 -1
  13. package/src/adapters/index.js +1 -2
  14. package/src/adapters/node/create-app.d.ts +1 -1
  15. package/src/adapters/node/create-app.js +14 -4
  16. package/src/adapters/node/server-adapter.d.ts +7 -1
  17. package/src/adapters/node/server-adapter.js +25 -3
  18. package/src/adapters/shared/fs-server-response-matcher.d.ts +2 -0
  19. package/src/adapters/shared/fs-server-response-matcher.js +39 -17
  20. package/src/dev/server-ready-message.d.ts +6 -0
  21. package/src/dev/server-ready-message.js +8 -0
  22. package/src/eco/README.md +11 -0
  23. package/src/eco/eco-declared-component.d.ts +14 -0
  24. package/src/eco/eco-declared-component.js +16 -0
  25. package/src/eco/eco.browser.js +5 -6
  26. package/src/eco/eco.js +5 -6
  27. package/src/eco/eco.types.d.ts +2 -2
  28. package/src/eco/page-layout-normalization.d.ts +21 -0
  29. package/src/eco/page-layout-normalization.js +59 -0
  30. package/src/errors/index.d.ts +1 -0
  31. package/src/errors/index.js +7 -1
  32. package/src/errors/undeclared-component-dependency-error.d.ts +11 -0
  33. package/src/errors/undeclared-component-dependency-error.js +17 -0
  34. package/src/route-renderer/orchestration/component-graph.js +12 -4
  35. package/src/route-renderer/orchestration/document-shell-render.service.d.ts +37 -1
  36. package/src/route-renderer/orchestration/document-shell-render.service.js +79 -13
  37. package/src/route-renderer/orchestration/integration-renderer.js +5 -1
  38. package/src/route-renderer/orchestration/layout-shell-props.service.d.ts +20 -0
  39. package/src/route-renderer/orchestration/layout-shell-props.service.js +31 -0
  40. package/src/route-renderer/orchestration/ownership-validation.service.d.ts +1 -0
  41. package/src/route-renderer/orchestration/ownership-validation.service.js +18 -0
  42. package/src/route-renderer/orchestration/route-prepared-options.builder.d.ts +3 -1
  43. package/src/route-renderer/orchestration/route-prepared-options.builder.js +3 -1
  44. package/src/route-renderer/orchestration/route-render-orchestrator.d.ts +3 -1
  45. package/src/route-renderer/orchestration/route-render-orchestrator.js +3 -3
  46. package/src/route-renderer/orchestration/string-markup-renderer.js +2 -1
  47. package/src/route-renderer/page-loading/component-dependency-collection.js +5 -2
  48. package/src/types/public-types.d.ts +51 -4
  49. package/src/utils/parse-cli-args.d.ts +1 -0
  50. package/src/utils/parse-cli-args.js +3 -0
  51. package/src/utils/server-utils.module.js +1 -0
  52. package/src/watchers/project-watcher-ignore.d.ts +11 -0
  53. package/src/watchers/project-watcher-ignore.js +16 -0
  54. package/src/watchers/project-watcher.js +2 -6
@@ -39,13 +39,13 @@ class FileSystemResponseMatcher {
39
39
  async handleNoMatch(requestUrl) {
40
40
  const isStaticFileRequest = ServerUtils.hasKnownExtension(requestUrl);
41
41
  if (!isStaticFileRequest) {
42
- return this.renderCustomNotFoundResponse();
42
+ return this.renderCustomNotFoundResponseOrServerError(requestUrl);
43
43
  }
44
44
  const relativeUrl = requestUrl.startsWith("/") ? requestUrl.slice(1) : requestUrl;
45
45
  const filePath = path.join(this.assetPrefix, relativeUrl);
46
46
  const contentType = ServerUtils.getContentType(filePath);
47
47
  const response = await this.fileSystemResponseFactory.createFileResponse(filePath, contentType);
48
- return response ?? this.renderCustomNotFoundResponse();
48
+ return response ?? this.renderCustomNotFoundResponseOrServerError(requestUrl);
49
49
  }
50
50
  /**
51
51
  * Handles a matched file-system page route.
@@ -100,17 +100,13 @@ class FileSystemResponseMatcher {
100
100
  return error;
101
101
  }
102
102
  if (error instanceof LocalsAccessError) {
103
- return new Response(error.message, {
104
- status: 500,
105
- headers: { "Content-Type": "text/plain; charset=utf-8" }
106
- });
107
- }
108
- if (error instanceof Error) {
109
- if (isDevelopmentRuntime() || appLogger.isDebugEnabled()) {
110
- appLogger.error(`[FileSystemResponseMatcher] ${error.message} at ${match.requestedPathname}`);
111
- }
103
+ return this.createInternalServerErrorResponse(error.message, match.requestedPathname, error);
112
104
  }
113
- return this.renderCustomNotFoundResponse();
105
+ return this.createInternalServerErrorResponse(
106
+ error instanceof Error ? error.message : "Internal Server Error",
107
+ match.requestedPathname,
108
+ error
109
+ );
114
110
  }
115
111
  }
116
112
  /**
@@ -119,12 +115,9 @@ class FileSystemResponseMatcher {
119
115
  */
120
116
  async renderCustomNotFoundResponse() {
121
117
  const error404TemplatePath = this.appConfig.absolutePaths.error404TemplatePath;
118
+ let routeRenderer;
122
119
  try {
123
- const routeRenderer = this.routeRendererFactory.getPageRenderer(error404TemplatePath);
124
- const result = await routeRenderer.execute({
125
- file: error404TemplatePath
126
- });
127
- return this.fileSystemResponseFactory.createHtmlNotFoundResponse(result.body);
120
+ routeRenderer = this.routeRendererFactory.getPageRenderer(error404TemplatePath);
128
121
  } catch {
129
122
  appLogger.debug(
130
123
  "Custom 404 template not found, falling back to default 404 response",
@@ -132,6 +125,35 @@ class FileSystemResponseMatcher {
132
125
  );
133
126
  return this.fileSystemResponseFactory.createDefaultNotFoundResponse();
134
127
  }
128
+ const result = await routeRenderer.execute({
129
+ file: error404TemplatePath
130
+ });
131
+ return this.fileSystemResponseFactory.createHtmlNotFoundResponse(result.body);
132
+ }
133
+ async renderCustomNotFoundResponseOrServerError(pathname) {
134
+ try {
135
+ return await this.renderCustomNotFoundResponse();
136
+ } catch (error) {
137
+ if (error instanceof Response) {
138
+ return error;
139
+ }
140
+ return this.createInternalServerErrorResponse(
141
+ error instanceof Error ? error.message : "Internal Server Error",
142
+ pathname,
143
+ error
144
+ );
145
+ }
146
+ }
147
+ createInternalServerErrorResponse(message, pathname, error) {
148
+ if (isDevelopmentRuntime() || appLogger.isDebugEnabled()) {
149
+ appLogger.error(`[FileSystemResponseMatcher] ${message} at ${pathname}`, error);
150
+ } else {
151
+ appLogger.error(`[FileSystemResponseMatcher] Render error at ${pathname}`, error);
152
+ }
153
+ return new Response("Internal Server Error", {
154
+ status: 500,
155
+ headers: { "Content-Type": "text/plain; charset=utf-8" }
156
+ });
135
157
  }
136
158
  async createExecutionPlan(match, request) {
137
159
  const cacheKey = this.pageRequestCacheCoordinator.buildCacheKey({
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Optional helpers for apps that want a machine-readable "ready" log line
3
+ * (for example Playwright `webServer` stdout matching). Pass to `app.start(onAppStart)`.
4
+ */
5
+ export declare const ECOPAGES_SERVER_READY_MARKER = "[@ecopages/ready]";
6
+ export declare function formatServerReadyMessage(origin: string): string;
@@ -0,0 +1,8 @@
1
+ const ECOPAGES_SERVER_READY_MARKER = "[@ecopages/ready]";
2
+ function formatServerReadyMessage(origin) {
3
+ return `${ECOPAGES_SERVER_READY_MARKER} ${origin.replace(/\/$/, "")}`;
4
+ }
5
+ export {
6
+ ECOPAGES_SERVER_READY_MARKER,
7
+ formatServerReadyMessage
8
+ };
package/src/eco/README.md CHANGED
@@ -279,6 +279,17 @@ export default eco.page({
279
279
  });
280
280
  ```
281
281
 
282
+ **Nested layouts (outer → inner):**
283
+
284
+ ```tsx
285
+ export default eco.page({
286
+ layout: [MarketingShell, DocsShell],
287
+ render: () => <h1>Hello</h1>,
288
+ });
289
+ ```
290
+
291
+ A single layout remains equivalent to a one-element array. Normalization stores the stack on `config.layouts` and `config.layoutEntries` at factory time.
292
+
282
293
  ### `eco.component()`
283
294
 
284
295
  Define a reusable component with dependencies.
@@ -0,0 +1,14 @@
1
+ import type { EcoDeclaredComponent } from '../types/public-types.js';
2
+ export { getUndeclaredComponentDependencyMessage } from '../errors/undeclared-component-dependency-error.js';
3
+ /**
4
+ * @remarks
5
+ * Declared components are produced by `eco.component()`, `eco.layout()`, and
6
+ * `eco.html()` after the component-meta plugin injects `config.__eco`.
7
+ */
8
+ export declare function isEcoDeclaredComponent(component: unknown): component is EcoDeclaredComponent;
9
+ /**
10
+ * @throws {UndeclaredComponentDependencyError} When the value lacks `config.__eco`.
11
+ */
12
+ export declare function assertEcoDeclaredComponent(component: unknown, context?: {
13
+ parentComponentFile?: string;
14
+ }): asserts component is EcoDeclaredComponent;
@@ -0,0 +1,16 @@
1
+ import { UndeclaredComponentDependencyError } from "../errors/undeclared-component-dependency-error.js";
2
+ import { getUndeclaredComponentDependencyMessage } from "../errors/undeclared-component-dependency-error.js";
3
+ function isEcoDeclaredComponent(component) {
4
+ return typeof component === "function" && typeof component.config?.__eco?.file === "string" && typeof component.config?.__eco?.integration === "string";
5
+ }
6
+ function assertEcoDeclaredComponent(component, context = {}) {
7
+ if (isEcoDeclaredComponent(component)) {
8
+ return;
9
+ }
10
+ throw new UndeclaredComponentDependencyError(context.parentComponentFile);
11
+ }
12
+ export {
13
+ assertEcoDeclaredComponent,
14
+ getUndeclaredComponentDependencyMessage,
15
+ isEcoDeclaredComponent
16
+ };
@@ -1,3 +1,4 @@
1
+ import { applyPageLayoutConfig, mergeLayoutDependencies, normalizePageLayouts } from "./page-layout-normalization.js";
1
2
  function createComponentFactory(options) {
2
3
  const component2 = ((props) => options.render(props));
3
4
  component2.config = {
@@ -32,17 +33,15 @@ function page(options) {
32
33
  requires,
33
34
  middleware
34
35
  } = options;
36
+ const layoutEntries = normalizePageLayouts(pageLayout);
35
37
  const pageComponent = createComponentFactory({
36
38
  __eco: options.__eco,
37
39
  integration: options.integration,
38
- dependencies: pageLayout ? {
39
- ...dependencies,
40
- components: [...dependencies?.components ?? [], pageLayout]
41
- } : dependencies,
40
+ dependencies: mergeLayoutDependencies(dependencies, layoutEntries),
42
41
  render
43
42
  });
44
- if (pageLayout && pageComponent.config) {
45
- pageComponent.config.layout = pageLayout;
43
+ if (pageComponent.config) {
44
+ applyPageLayoutConfig(pageComponent.config, layoutEntries);
46
45
  }
47
46
  if (staticPaths2) {
48
47
  pageComponent.staticPaths = staticPaths2;
package/src/eco/eco.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  interceptForeignChild
5
5
  } from "../route-renderer/orchestration/component-render-context.js";
6
6
  import { isThenable } from "../route-renderer/orchestration/render-output.utils.js";
7
+ import { applyPageLayoutConfig, mergeLayoutDependencies, normalizePageLayouts } from "./page-layout-normalization.js";
7
8
  function createComponentFactory(options) {
8
9
  const integrationName = options.integration ?? options.__eco?.integration;
9
10
  const comp = ((props) => {
@@ -74,18 +75,16 @@ function layout(options) {
74
75
  }
75
76
  function page(options) {
76
77
  const { layout: layout2, dependencies, render, staticPaths: staticPaths2, staticProps: staticProps2, metadata: metadata2, cache, requires, middleware } = options;
78
+ const layoutEntries = normalizePageLayouts(layout2);
77
79
  const componentOptions = {
78
80
  __eco: options.__eco,
79
81
  integration: options.integration,
80
- dependencies: layout2 ? {
81
- ...dependencies,
82
- components: [...dependencies?.components || [], layout2]
83
- } : dependencies,
82
+ dependencies: mergeLayoutDependencies(dependencies, layoutEntries),
84
83
  render
85
84
  };
86
85
  const pageComponent = createComponentFactory(componentOptions);
87
- if (layout2 && pageComponent.config) {
88
- pageComponent.config.layout = layout2;
86
+ if (pageComponent.config) {
87
+ applyPageLayoutConfig(pageComponent.config, layoutEntries);
89
88
  }
90
89
  if (staticPaths2) pageComponent.staticPaths = staticPaths2;
91
90
  if (staticProps2) pageComponent.staticProps = staticProps2;
@@ -2,7 +2,7 @@
2
2
  * Type definitions for the eco namespace API
3
3
  * @module
4
4
  */
5
- import type { DependencyLazyTrigger, EcoComponent, EcoComponentDependencies, EcoHtmlComponent, EcoInjectedMeta, EcoLayoutComponent, EcoPageLayoutComponent, EcoPagesElement, FileRouteMiddleware, GetMetadata, GetStaticPaths, GetStaticProps, HtmlTemplateProps, LayoutProps, RequestLocals, RequestPageContext } from '../types/public-types.js';
5
+ import type { DependencyLazyTrigger, EcoComponent, EcoComponentDependencies, EcoHtmlComponent, EcoInjectedMeta, EcoLayoutComponent, EcoPageLayouts, EcoPagesElement, FileRouteMiddleware, GetMetadata, GetStaticPaths, GetStaticProps, HtmlTemplateProps, LayoutProps, RequestLocals, RequestPageContext } from '../types/public-types.js';
6
6
  import type { CacheStrategy } from '../services/cache/cache.types.js';
7
7
  /**
8
8
  * Extracts the props type from one eco component.
@@ -81,7 +81,7 @@ export interface PageOptionsBase<T, E = EcoPagesElement> {
81
81
  __eco?: EcoInjectedMeta;
82
82
  integration?: string;
83
83
  dependencies?: EcoComponentDependencies;
84
- layout?: EcoPageLayoutComponent<E>;
84
+ layout?: EcoPageLayouts<E>;
85
85
  /**
86
86
  * Define static paths for dynamic routes (e.g., [slug].tsx).
87
87
  * Returns all possible paths that should be pre-rendered at build time.
@@ -0,0 +1,21 @@
1
+ import type { EcoComponentConfig, EcoComponentDependencies, EcoPageLayoutEntry, EcoPageLayouts } from '../types/public-types.js';
2
+ /**
3
+ * Normalizes `layout` page options to an outer→inner stack of layout entries.
4
+ */
5
+ export declare function normalizePageLayouts<E>(layout?: EcoPageLayouts<E>): EcoPageLayoutEntry<E>[];
6
+ /**
7
+ * Merges layout components into `dependencies.components` without duplicates.
8
+ */
9
+ export declare function mergeLayoutDependencies(dependencies: EcoComponentDependencies | undefined, layoutEntries: EcoPageLayoutEntry[]): EcoComponentDependencies | undefined;
10
+ /**
11
+ * Writes normalized layout metadata onto a page `config`.
12
+ */
13
+ export declare function applyPageLayoutConfig(pageConfig: EcoComponentConfig, layoutEntries: EcoPageLayoutEntry[]): void;
14
+ /**
15
+ * Migrates MDX-exported `config.layout` values onto normalized layout metadata.
16
+ *
17
+ * @remarks
18
+ * `eco.page({ layout })` already normalizes at factory time. MDX modules that
19
+ * export `config.layout` directly still need this import-time migration.
20
+ */
21
+ export declare function ensurePageConfigLayouts(config: EcoComponentConfig | undefined): EcoComponentConfig | undefined;
@@ -0,0 +1,59 @@
1
+ function isLayoutSpecObject(spec) {
2
+ return typeof spec === "object" && spec !== null && "component" in spec;
3
+ }
4
+ function normalizePageLayouts(layout) {
5
+ if (!layout) {
6
+ return [];
7
+ }
8
+ const specs = Array.isArray(layout) ? layout : [layout];
9
+ return specs.map((spec) => {
10
+ if (isLayoutSpecObject(spec)) {
11
+ return {
12
+ component: spec.component,
13
+ props: spec.props
14
+ };
15
+ }
16
+ return {
17
+ component: spec
18
+ };
19
+ });
20
+ }
21
+ function mergeLayoutDependencies(dependencies, layoutEntries) {
22
+ if (layoutEntries.length === 0) {
23
+ return dependencies;
24
+ }
25
+ const mergedComponents = [...dependencies?.components ?? []];
26
+ for (const entry of layoutEntries) {
27
+ if (!mergedComponents.includes(entry.component)) {
28
+ mergedComponents.push(entry.component);
29
+ }
30
+ }
31
+ return {
32
+ ...dependencies,
33
+ components: mergedComponents
34
+ };
35
+ }
36
+ function applyPageLayoutConfig(pageConfig, layoutEntries) {
37
+ if (layoutEntries.length === 0) {
38
+ return;
39
+ }
40
+ pageConfig.layouts = layoutEntries.map((entry) => entry.component);
41
+ pageConfig.layoutEntries = layoutEntries;
42
+ }
43
+ function ensurePageConfigLayouts(config) {
44
+ if (!config || config.layouts && config.layouts.length > 0) {
45
+ return config;
46
+ }
47
+ const legacyLayout = config.layout;
48
+ if (!legacyLayout) {
49
+ return config;
50
+ }
51
+ applyPageLayoutConfig(config, normalizePageLayouts(legacyLayout));
52
+ return config;
53
+ }
54
+ export {
55
+ applyPageLayoutConfig,
56
+ ensurePageConfigLayouts,
57
+ mergeLayoutDependencies,
58
+ normalizePageLayouts
59
+ };
@@ -1,3 +1,4 @@
1
1
  export { HttpError } from './http-error.js';
2
2
  export type { HttpErrorDetails, HttpErrorJson } from './http-error.js';
3
3
  export { LocalsAccessError } from './locals-access-error.js';
4
+ export { UndeclaredComponentDependencyError, getUndeclaredComponentDependencyMessage, } from './undeclared-component-dependency-error.js';
@@ -1,6 +1,12 @@
1
1
  import { HttpError } from "./http-error.js";
2
2
  import { LocalsAccessError } from "./locals-access-error.js";
3
+ import {
4
+ UndeclaredComponentDependencyError,
5
+ getUndeclaredComponentDependencyMessage
6
+ } from "./undeclared-component-dependency-error.js";
3
7
  export {
4
8
  HttpError,
5
- LocalsAccessError
9
+ LocalsAccessError,
10
+ UndeclaredComponentDependencyError,
11
+ getUndeclaredComponentDependencyMessage
6
12
  };
@@ -0,0 +1,11 @@
1
+ export declare function getUndeclaredComponentDependencyMessage(parentComponentFile?: string): string;
2
+ /**
3
+ * Thrown when `dependencies.components` contains a plain function or component
4
+ * without plugin-injected `config.__eco` metadata.
5
+ */
6
+ export declare class UndeclaredComponentDependencyError extends Error {
7
+ name: string;
8
+ /** Parent component file path when the invalid entry was declared. */
9
+ readonly parentComponentFile?: string;
10
+ constructor(parentComponentFile?: string);
11
+ }
@@ -0,0 +1,17 @@
1
+ function getUndeclaredComponentDependencyMessage(parentComponentFile) {
2
+ const parentHint = parentComponentFile ? ` (declared by ${parentComponentFile})` : "";
3
+ return `[ecopages] dependencies.components entries must be eco.component(), eco.layout(), or eco.html() components with __eco metadata${parentHint}. Plain functions and untyped components are not valid dependency entries.`;
4
+ }
5
+ class UndeclaredComponentDependencyError extends Error {
6
+ name = "UndeclaredComponentDependencyError";
7
+ /** Parent component file path when the invalid entry was declared. */
8
+ parentComponentFile;
9
+ constructor(parentComponentFile) {
10
+ super(getUndeclaredComponentDependencyMessage(parentComponentFile));
11
+ this.parentComponentFile = parentComponentFile;
12
+ }
13
+ }
14
+ export {
15
+ UndeclaredComponentDependencyError,
16
+ getUndeclaredComponentDependencyMessage
17
+ };
@@ -25,8 +25,12 @@ function walkComponentGraph(input) {
25
25
  return;
26
26
  }
27
27
  }
28
- if (input.visitLayout && ecoComponent.config?.layout?.config) {
29
- visitConfig(ecoComponent.config.layout.config, integrationName);
28
+ if (input.visitLayout) {
29
+ for (const layout of ecoComponent.config?.layouts ?? []) {
30
+ if (layout?.config) {
31
+ visitConfig(layout.config, integrationName);
32
+ }
33
+ }
30
34
  }
31
35
  for (const child of ecoComponent.config?.dependencies?.components ?? []) {
32
36
  visitComponent(child, integrationName, "dependency");
@@ -40,8 +44,12 @@ function walkComponentGraph(input) {
40
44
  if (input.onConfig) {
41
45
  input.onConfig(config);
42
46
  }
43
- if (input.visitLayout && config.layout?.config) {
44
- visitConfig(config.layout.config, parentIntegrationName);
47
+ if (input.visitLayout) {
48
+ for (const layout of config.layouts ?? []) {
49
+ if (layout?.config) {
50
+ visitConfig(layout.config, parentIntegrationName);
51
+ }
52
+ }
45
53
  }
46
54
  for (const child of config.dependencies?.components ?? []) {
47
55
  visitComponent(child, parentIntegrationName, "dependency");
@@ -1,13 +1,43 @@
1
- import type { ComponentRenderInput, ComponentRenderResult, EcoComponent, PageMetadataProps } from '../../types/public-types.js';
1
+ import type { BaseIntegrationContext, ComponentRenderInput, ComponentRenderResult, EcoComponent, PageMetadataProps } from '../../types/public-types.js';
2
2
  import type { ProcessedAsset } from '../../services/assets/asset-processing-service/index.js';
3
3
  export type DocumentShellLayoutInput = {
4
4
  component: EcoComponent;
5
5
  props?: Record<string, unknown>;
6
6
  };
7
+ export type DocumentShellComposeChildrenContext = {
8
+ primaryComponent: EcoComponent;
9
+ primaryProps: Record<string, unknown>;
10
+ primaryRender?: ComponentRenderResult;
11
+ layouts: DocumentShellLayoutInput[];
12
+ rendererCache: BaseIntegrationContext['rendererCache'];
13
+ renderComponentWithForeignChildren: DocumentShellRenderDependencies['renderComponentWithForeignChildren'];
14
+ };
15
+ export type DocumentShellComposeChildrenResult = {
16
+ /** Serialized or element children passed to the HTML template shell. */
17
+ children: unknown;
18
+ layoutRenders: ComponentRenderResult[];
19
+ /**
20
+ * Primary/page render assets when the hook performs unified composition.
21
+ *
22
+ * @remarks
23
+ * Omit only when the default sequential string path runs (core renders primary first).
24
+ */
25
+ primaryRender?: ComponentRenderResult;
26
+ };
27
+ /**
28
+ * Optional hook that replaces default sequential string layout wrapping.
29
+ *
30
+ * @remarks
31
+ * When provided, core skips the initial primary render and expects the hook to return
32
+ * `children` for the HTML template. Supply `primaryRender` so dependency assets still merge.
33
+ */
34
+ export type DocumentShellComposeChildrenHook = (context: DocumentShellComposeChildrenContext) => Promise<DocumentShellComposeChildrenResult>;
7
35
  export type DocumentShellComposeInput = {
8
36
  primaryComponent: EcoComponent;
9
37
  primaryProps: Record<string, unknown>;
10
38
  layout?: DocumentShellLayoutInput;
39
+ layouts?: DocumentShellLayoutInput[];
40
+ composeChildren?: DocumentShellComposeChildrenHook;
11
41
  htmlTemplate: EcoComponent;
12
42
  documentProps: Record<string, unknown>;
13
43
  };
@@ -17,6 +47,8 @@ export type DocumentShellPageRenderInput = {
17
47
  props: Record<string, unknown>;
18
48
  };
19
49
  layout?: DocumentShellLayoutInput;
50
+ layouts?: DocumentShellLayoutInput[];
51
+ composeChildren?: DocumentShellComposeChildrenHook;
20
52
  htmlTemplate: EcoComponent;
21
53
  metadata: PageMetadataProps;
22
54
  pageProps: Record<string, unknown>;
@@ -31,6 +63,10 @@ export type DocumentShellAttributeStamping = {
31
63
  applyAttributesToFirstBodyElement(html: string, attributes: Record<string, string>): string;
32
64
  applyAttributesToHtmlElement(html: string, attributes: Record<string, string>): string;
33
65
  };
66
+ /**
67
+ * Default sequential string-child composition for document shell layers.
68
+ */
69
+ export declare function composeSequentialLayoutChildren(context: DocumentShellComposeChildrenContext): Promise<DocumentShellComposeChildrenResult>;
34
70
  /**
35
71
  * Composes page or view content through optional layout and document shells.
36
72
  *
@@ -1,23 +1,86 @@
1
+ function resolveDocumentShellLayouts(input) {
2
+ if (input.layouts && input.layouts.length > 0) {
3
+ return input.layouts;
4
+ }
5
+ return input.layout ? [input.layout] : [];
6
+ }
7
+ function resolveComponentIntegrationName(component) {
8
+ return component.config?.integration ?? component.config?.__eco?.integration ?? "ecopages";
9
+ }
10
+ async function composeSequentialLayoutChildren(context) {
11
+ if (!context.primaryRender) {
12
+ throw new Error("[ecopages] composeSequentialLayoutChildren requires primaryRender.");
13
+ }
14
+ let children = context.primaryRender.html;
15
+ const layoutRenders = [];
16
+ for (const layout of [...context.layouts].reverse()) {
17
+ const layoutRender = await context.renderComponentWithForeignChildren({
18
+ component: layout.component,
19
+ props: layout.props ?? {},
20
+ children,
21
+ integrationContext: { rendererCache: context.rendererCache }
22
+ });
23
+ layoutRenders.push(layoutRender);
24
+ children = layoutRender.html;
25
+ }
26
+ return {
27
+ children,
28
+ layoutRenders
29
+ };
30
+ }
1
31
  async function composeDocumentShell(dependencies, input) {
2
32
  const rendererCache = /* @__PURE__ */ new Map();
3
- const primaryRender = await dependencies.renderComponentWithForeignChildren({
4
- component: input.primaryComponent,
5
- props: input.primaryProps,
6
- integrationContext: { rendererCache }
7
- });
8
- const layoutRender = input.layout ? await dependencies.renderComponentWithForeignChildren({
9
- component: input.layout.component,
10
- props: input.layout.props ?? {},
11
- children: primaryRender.html,
12
- integrationContext: { rendererCache }
13
- }) : void 0;
33
+ const layouts = resolveDocumentShellLayouts(input);
34
+ const composeChildren = input.composeChildren ?? composeSequentialLayoutChildren;
35
+ const usesCustomComposeChildren = input.composeChildren !== void 0;
36
+ let primaryRender;
37
+ let children;
38
+ let layoutRenders;
39
+ if (usesCustomComposeChildren) {
40
+ const composed = await composeChildren({
41
+ primaryComponent: input.primaryComponent,
42
+ primaryProps: input.primaryProps,
43
+ layouts,
44
+ rendererCache,
45
+ renderComponentWithForeignChildren: dependencies.renderComponentWithForeignChildren
46
+ });
47
+ children = composed.children;
48
+ layoutRenders = composed.layoutRenders;
49
+ primaryRender = composed.primaryRender ?? {
50
+ html: typeof composed.children === "string" ? composed.children : "",
51
+ assets: [],
52
+ canAttachAttributes: true,
53
+ rootTag: "main",
54
+ integrationName: resolveComponentIntegrationName(input.primaryComponent)
55
+ };
56
+ } else {
57
+ primaryRender = await dependencies.renderComponentWithForeignChildren({
58
+ component: input.primaryComponent,
59
+ props: input.primaryProps,
60
+ integrationContext: { rendererCache }
61
+ });
62
+ const composed = await composeChildren({
63
+ primaryComponent: input.primaryComponent,
64
+ primaryProps: input.primaryProps,
65
+ primaryRender,
66
+ layouts,
67
+ rendererCache,
68
+ renderComponentWithForeignChildren: dependencies.renderComponentWithForeignChildren
69
+ });
70
+ children = composed.children;
71
+ layoutRenders = composed.layoutRenders;
72
+ }
14
73
  const documentRender = await dependencies.renderComponentWithForeignChildren({
15
74
  component: input.htmlTemplate,
16
75
  props: input.documentProps,
17
- children: layoutRender?.html ?? primaryRender.html,
76
+ children,
18
77
  integrationContext: { rendererCache }
19
78
  });
20
- dependencies.appendProcessedDependencies(primaryRender.assets, layoutRender?.assets, documentRender.assets);
79
+ dependencies.appendProcessedDependencies(
80
+ primaryRender.assets,
81
+ ...layoutRenders.map((layoutRender) => layoutRender.assets),
82
+ documentRender.assets
83
+ );
21
84
  return {
22
85
  documentHtml: documentRender.html
23
86
  };
@@ -37,6 +100,8 @@ async function renderPageDocumentShell(dependencies, input, docType) {
37
100
  primaryComponent: input.page.component,
38
101
  primaryProps: input.page.props,
39
102
  layout: input.layout,
103
+ layouts: input.layouts,
104
+ composeChildren: input.composeChildren,
40
105
  htmlTemplate: input.htmlTemplate,
41
106
  documentProps: {
42
107
  metadata: input.metadata,
@@ -50,5 +115,6 @@ async function renderPageDocumentShell(dependencies, input, docType) {
50
115
  export {
51
116
  applyDocumentShellAttributeStamping,
52
117
  composeDocumentShell,
118
+ composeSequentialLayoutChildren,
53
119
  renderPageDocumentShell
54
120
  };
@@ -23,6 +23,7 @@ import {
23
23
  composeDocumentShell,
24
24
  renderPageDocumentShell
25
25
  } from "./document-shell-render.service.js";
26
+ import { resolveInnermostPageLayout, resolvePageLayoutComponents } from "./layout-shell-props.service.js";
26
27
  class IntegrationRenderer {
27
28
  appConfig;
28
29
  assetProcessingService;
@@ -594,7 +595,8 @@ class IntegrationRenderer {
594
595
  });
595
596
  const { Page, integrationSpecificProps } = pageModule;
596
597
  const HtmlTemplate = await this.getHtmlTemplate();
597
- const Layout = Page.config?.layout;
598
+ const Layouts = resolvePageLayoutComponents(Page.config?.layouts);
599
+ const Layout = resolveInnermostPageLayout(Layouts);
598
600
  const { props, metadata } = await this.pageModuleLoaderService.resolvePageData({
599
601
  pageModule,
600
602
  routeOptions
@@ -602,7 +604,9 @@ class IntegrationRenderer {
602
604
  return {
603
605
  Page,
604
606
  HtmlTemplate,
607
+ Layouts,
605
608
  Layout,
609
+ layoutEntries: Page.config?.layoutEntries,
606
610
  props,
607
611
  metadata,
608
612
  integrationSpecificProps
@@ -0,0 +1,20 @@
1
+ import type { EcoPageLayoutEntry, LayoutPropsContext } from '../../types/public-types.js';
2
+ export type LayoutShellPropsContext = LayoutPropsContext & {
3
+ pageProps?: Record<string, unknown>;
4
+ };
5
+ /**
6
+ * Resolves the default shell props passed to a layout tier during route render.
7
+ */
8
+ export declare function resolveLayoutShellProps(context: LayoutShellPropsContext): Record<string, unknown>;
9
+ /**
10
+ * Resolves props for one layout entry, merging shell props with an optional factory.
11
+ */
12
+ export declare function resolveLayoutEntryProps(entry: EcoPageLayoutEntry, context: LayoutShellPropsContext): Record<string, unknown>;
13
+ /**
14
+ * Reads the normalized layout stack from a page component config.
15
+ */
16
+ export declare function resolvePageLayoutComponents(layouts?: EcoPageLayoutEntry['component'][]): EcoPageLayoutEntry['component'][];
17
+ /**
18
+ * Returns the innermost layout component from a normalized page layout stack.
19
+ */
20
+ export declare function resolveInnermostPageLayout(layouts?: EcoPageLayoutEntry['component'][]): EcoPageLayoutEntry['component'] | undefined;
@@ -0,0 +1,31 @@
1
+ function resolveLayoutShellProps(context) {
2
+ return context.locals ? { locals: context.locals } : {};
3
+ }
4
+ function resolveLayoutEntryProps(entry, context) {
5
+ const shellProps = resolveLayoutShellProps(context);
6
+ if (!entry.props) {
7
+ return shellProps;
8
+ }
9
+ const layoutPropsContext = {
10
+ params: context.params,
11
+ query: context.query,
12
+ locals: context.locals
13
+ };
14
+ return {
15
+ ...shellProps,
16
+ ...entry.props(layoutPropsContext)
17
+ };
18
+ }
19
+ function resolvePageLayoutComponents(layouts) {
20
+ return layouts && layouts.length > 0 ? layouts : [];
21
+ }
22
+ function resolveInnermostPageLayout(layouts) {
23
+ const stack = resolvePageLayoutComponents(layouts);
24
+ return stack[stack.length - 1];
25
+ }
26
+ export {
27
+ resolveInnermostPageLayout,
28
+ resolveLayoutEntryProps,
29
+ resolveLayoutShellProps,
30
+ resolvePageLayoutComponents
31
+ };