@useavalon/avalon 0.1.0
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 +54 -0
- package/mod.ts +301 -0
- package/package.json +85 -0
- package/src/build/README.md +310 -0
- package/src/build/integration-bundler-plugin.ts +116 -0
- package/src/build/integration-config.ts +168 -0
- package/src/build/integration-detection-plugin.ts +117 -0
- package/src/build/integration-resolver-plugin.ts +90 -0
- package/src/build/island-manifest.ts +269 -0
- package/src/build/island-types-generator.ts +476 -0
- package/src/build/mdx-island-transform.ts +464 -0
- package/src/build/mdx-plugin.ts +98 -0
- package/src/build/page-island-transform.ts +598 -0
- package/src/build/prop-extractors/index.ts +21 -0
- package/src/build/prop-extractors/lit.ts +140 -0
- package/src/build/prop-extractors/qwik.ts +16 -0
- package/src/build/prop-extractors/solid.ts +125 -0
- package/src/build/prop-extractors/svelte.ts +194 -0
- package/src/build/prop-extractors/vue.ts +111 -0
- package/src/build/sidecar-file-manager.ts +104 -0
- package/src/build/sidecar-renderer.ts +30 -0
- package/src/client/adapters/index.ts +13 -0
- package/src/client/adapters/lit-adapter.ts +654 -0
- package/src/client/adapters/preact-adapter.ts +331 -0
- package/src/client/adapters/qwik-adapter.ts +345 -0
- package/src/client/adapters/react-adapter.ts +353 -0
- package/src/client/adapters/solid-adapter.ts +451 -0
- package/src/client/adapters/svelte-adapter.ts +524 -0
- package/src/client/adapters/vue-adapter.ts +467 -0
- package/src/client/components.ts +35 -0
- package/src/client/css-hmr-handler.ts +344 -0
- package/src/client/framework-adapter.ts +462 -0
- package/src/client/hmr-coordinator.ts +396 -0
- package/src/client/hmr-error-overlay.js +533 -0
- package/src/client/main.js +816 -0
- package/src/client/tests/css-hmr-handler.test.ts +360 -0
- package/src/client/tests/framework-adapter.test.ts +519 -0
- package/src/client/tests/hmr-coordinator.test.ts +176 -0
- package/src/client/tests/hydration-option-parsing.test.ts +107 -0
- package/src/client/tests/lit-adapter.test.ts +427 -0
- package/src/client/tests/preact-adapter.test.ts +353 -0
- package/src/client/tests/qwik-adapter.test.ts +343 -0
- package/src/client/tests/react-adapter.test.ts +317 -0
- package/src/client/tests/solid-adapter.test.ts +396 -0
- package/src/client/tests/svelte-adapter.test.ts +387 -0
- package/src/client/tests/vue-adapter.test.ts +407 -0
- package/src/client/types/framework-runtime.d.ts +68 -0
- package/src/client/types/vite-hmr.d.ts +46 -0
- package/src/client/types/vite-virtual-modules.d.ts +60 -0
- package/src/components/Image.tsx +123 -0
- package/src/components/IslandErrorBoundary.tsx +145 -0
- package/src/components/LayoutDataErrorBoundary.tsx +141 -0
- package/src/components/LayoutErrorBoundary.tsx +127 -0
- package/src/components/PersistentIsland.tsx +52 -0
- package/src/components/StreamingErrorBoundary.tsx +233 -0
- package/src/components/StreamingLayout.tsx +538 -0
- package/src/components/tests/component-analyzer.test.ts +96 -0
- package/src/components/tests/component-detection.test.ts +347 -0
- package/src/components/tests/persistent-islands.test.ts +398 -0
- package/src/core/components/component-analyzer.ts +192 -0
- package/src/core/components/component-detection.ts +508 -0
- package/src/core/components/enhanced-framework-detector.ts +500 -0
- package/src/core/components/framework-registry.ts +563 -0
- package/src/core/components/tests/enhanced-framework-detector.test.ts +577 -0
- package/src/core/components/tests/framework-registry.test.ts +465 -0
- package/src/core/content/mdx-processor.ts +46 -0
- package/src/core/integrations/README.md +282 -0
- package/src/core/integrations/index.ts +19 -0
- package/src/core/integrations/loader.ts +125 -0
- package/src/core/integrations/registry.ts +195 -0
- package/src/core/islands/island-persistence.ts +325 -0
- package/src/core/islands/island-state-serializer.ts +258 -0
- package/src/core/islands/persistent-island-context.tsx +80 -0
- package/src/core/islands/use-persistent-state.ts +68 -0
- package/src/core/layout/enhanced-layout-resolver.ts +322 -0
- package/src/core/layout/layout-cache-manager.ts +485 -0
- package/src/core/layout/layout-composer.ts +357 -0
- package/src/core/layout/layout-data-loader.ts +516 -0
- package/src/core/layout/layout-discovery.ts +243 -0
- package/src/core/layout/layout-matcher.ts +299 -0
- package/src/core/layout/layout-types.ts +110 -0
- package/src/core/layout/tests/enhanced-layout-resolver.test.ts +477 -0
- package/src/core/layout/tests/layout-cache-optimization.test.ts +149 -0
- package/src/core/layout/tests/layout-composer.test.ts +486 -0
- package/src/core/layout/tests/layout-data-loader.test.ts +443 -0
- package/src/core/layout/tests/layout-discovery.test.ts +253 -0
- package/src/core/layout/tests/layout-matcher.test.ts +480 -0
- package/src/core/modules/framework-module-resolver.ts +273 -0
- package/src/core/modules/tests/framework-module-resolver.test.ts +263 -0
- package/src/core/modules/tests/module-resolution-integration.test.ts +117 -0
- package/src/islands/component-analysis.ts +213 -0
- package/src/islands/css-utils.ts +565 -0
- package/src/islands/discovery/index.ts +80 -0
- package/src/islands/discovery/registry.ts +340 -0
- package/src/islands/discovery/resolver.ts +477 -0
- package/src/islands/discovery/scanner.ts +386 -0
- package/src/islands/discovery/tests/island-discovery.test.ts +881 -0
- package/src/islands/discovery/types.ts +117 -0
- package/src/islands/discovery/validator.ts +544 -0
- package/src/islands/discovery/watcher.ts +368 -0
- package/src/islands/framework-detection.ts +428 -0
- package/src/islands/integration-loader.ts +490 -0
- package/src/islands/island.tsx +565 -0
- package/src/islands/render-cache.ts +550 -0
- package/src/islands/types.ts +80 -0
- package/src/islands/universal-css-collector.ts +157 -0
- package/src/islands/universal-head-collector.ts +137 -0
- package/src/layout-system.d.ts +592 -0
- package/src/layout-system.ts +218 -0
- package/src/middleware/__tests__/discovery.test.ts +107 -0
- package/src/middleware/discovery.ts +268 -0
- package/src/middleware/executor.ts +315 -0
- package/src/middleware/index.ts +76 -0
- package/src/middleware/types.ts +99 -0
- package/src/nitro/build-config.ts +576 -0
- package/src/nitro/config.ts +483 -0
- package/src/nitro/error-handler.ts +636 -0
- package/src/nitro/index.ts +173 -0
- package/src/nitro/island-manifest.ts +584 -0
- package/src/nitro/middleware-adapter.ts +260 -0
- package/src/nitro/renderer.ts +1458 -0
- package/src/nitro/route-discovery.ts +439 -0
- package/src/nitro/types.ts +321 -0
- package/src/render/collect-css.ts +198 -0
- package/src/render/error-pages.ts +79 -0
- package/src/render/isolated-ssr-renderer.ts +654 -0
- package/src/render/ssr.ts +1030 -0
- package/src/schemas/api.ts +30 -0
- package/src/schemas/core.ts +64 -0
- package/src/schemas/index.ts +212 -0
- package/src/schemas/layout.ts +279 -0
- package/src/schemas/routing/index.ts +38 -0
- package/src/schemas/routing.ts +376 -0
- package/src/types/as-island.ts +20 -0
- package/src/types/image.d.ts +106 -0
- package/src/types/index.d.ts +22 -0
- package/src/types/island-jsx.d.ts +33 -0
- package/src/types/island-prop.d.ts +20 -0
- package/src/types/layout.ts +285 -0
- package/src/types/mdx.d.ts +6 -0
- package/src/types/routing.ts +555 -0
- package/src/types/tests/layout-types.test.ts +197 -0
- package/src/types/types.ts +5 -0
- package/src/types/urlpattern.d.ts +49 -0
- package/src/types/vite-env.d.ts +11 -0
- package/src/utils/dev-logger.ts +299 -0
- package/src/utils/fs.ts +151 -0
- package/src/vite-plugin/auto-discover.ts +551 -0
- package/src/vite-plugin/config.ts +266 -0
- package/src/vite-plugin/errors.ts +127 -0
- package/src/vite-plugin/image-optimization.ts +151 -0
- package/src/vite-plugin/integration-activator.ts +126 -0
- package/src/vite-plugin/island-sidecar-plugin.ts +176 -0
- package/src/vite-plugin/module-discovery.ts +189 -0
- package/src/vite-plugin/nitro-integration.ts +1334 -0
- package/src/vite-plugin/plugin.ts +329 -0
- package/src/vite-plugin/tests/image-optimization.test.ts +54 -0
- package/src/vite-plugin/types.ts +327 -0
- package/src/vite-plugin/validation.ts +228 -0
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
import { type JSX, h } from 'preact';
|
|
2
|
+
import { render as preactRenderToString } from 'preact-render-to-string';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import type { RenderOptions } from '../schemas/core.ts';
|
|
5
|
+
import { getUniversalCSSForHead } from '../islands/universal-css-collector.ts';
|
|
6
|
+
import { getUniversalHeadForInjection } from '../islands/universal-head-collector.ts';
|
|
7
|
+
import { analyzeComponentContent, type AnalyzerOptions } from '../core/components/component-analyzer.ts';
|
|
8
|
+
import type { EnhancedLayoutResolver } from '../core/layout/enhanced-layout-resolver.ts';
|
|
9
|
+
import type { LayoutContext, PageModule } from '../types/layout.ts';
|
|
10
|
+
import { IsolatedSSRRenderer, type SSRIsolationConfig } from './isolated-ssr-renderer.ts';
|
|
11
|
+
|
|
12
|
+
export interface RouteConfig {
|
|
13
|
+
component: () => JSX.Element | Promise<JSX.Element>;
|
|
14
|
+
options?: Partial<RenderOptions>;
|
|
15
|
+
frontmatter?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RenderStrategy {
|
|
19
|
+
type: 'hydrate' | 'ssr-only';
|
|
20
|
+
reason: string;
|
|
21
|
+
warnings?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Automatically injects the client-side hydration script and CSS if not already present
|
|
26
|
+
*/
|
|
27
|
+
function injectClientScript(html: string): string {
|
|
28
|
+
let modifiedHtml = html;
|
|
29
|
+
|
|
30
|
+
// Check if there are any islands that need hydration
|
|
31
|
+
const hasIslands = html.includes('data-framework=') || html.includes('data-src=');
|
|
32
|
+
|
|
33
|
+
if (!hasIslands) {
|
|
34
|
+
// No islands found, no need to inject anything
|
|
35
|
+
return html;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Inject universal CSS into the head if not already present
|
|
39
|
+
if (!html.includes('data-universal-ssr="true"')) {
|
|
40
|
+
const universalCSS = getUniversalCSSForHead(true); // Clear after collecting
|
|
41
|
+
if (universalCSS && html.includes('</head>')) {
|
|
42
|
+
modifiedHtml = modifiedHtml.replace('</head>', `${universalCSS}\n</head>`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Inject universal head content (hydration scripts, etc.) into the head
|
|
47
|
+
const universalHead = getUniversalHeadForInjection(true); // Clear after collecting
|
|
48
|
+
if (universalHead && html.includes('</head>')) {
|
|
49
|
+
modifiedHtml = modifiedHtml.replace('</head>', ` ${universalHead}\n</head>`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check if the client script is already included
|
|
53
|
+
if (html.includes('/src/client/main.js') || html.includes('main.js')) {
|
|
54
|
+
return modifiedHtml;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Inject the client script before the closing </body> tag
|
|
58
|
+
const clientScript = '<script type="module" src="/src/client/main.js"></script>';
|
|
59
|
+
|
|
60
|
+
if (modifiedHtml.includes('</body>')) {
|
|
61
|
+
return modifiedHtml.replace('</body>', `${clientScript}\n</body>`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fallback: append to the end if no </body> tag found
|
|
65
|
+
return modifiedHtml + clientScript;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ComponentRenderOptions {
|
|
69
|
+
forceSSROnly?: boolean;
|
|
70
|
+
detectScripts?: boolean;
|
|
71
|
+
suppressWarnings?: boolean;
|
|
72
|
+
logDecisions?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface FrameworkDetection {
|
|
76
|
+
solid: boolean;
|
|
77
|
+
vue: boolean;
|
|
78
|
+
svelte: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Framework detection patterns
|
|
82
|
+
const FRAMEWORK_PATTERNS = {
|
|
83
|
+
solid: ['solid-js', 'SolidIsland', 'createSignal', '.solid.', 'data-solid-hydrate'],
|
|
84
|
+
vue: ['data-vue-hydrate', '.vue', 'Vue'],
|
|
85
|
+
svelte: ['data-framework="svelte"', '.svelte', 's-'],
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
// Global isolated SSR renderer instance
|
|
89
|
+
let isolatedRenderer: IsolatedSSRRenderer | null = null;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Gets or creates the isolated SSR renderer
|
|
93
|
+
*/
|
|
94
|
+
function getIsolatedRenderer(): IsolatedSSRRenderer {
|
|
95
|
+
if (!isolatedRenderer) {
|
|
96
|
+
const config: Partial<SSRIsolationConfig> = {
|
|
97
|
+
enableStrictIsolation: true,
|
|
98
|
+
allowedCrossFrameworkImports: ['preact', 'preact-render-to-string'],
|
|
99
|
+
errorHandling: 'fallback',
|
|
100
|
+
debugLogging: process.env.NODE_ENV !== 'production',
|
|
101
|
+
};
|
|
102
|
+
isolatedRenderer = new IsolatedSSRRenderer(config);
|
|
103
|
+
}
|
|
104
|
+
return isolatedRenderer;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function detectFrameworks(content: string): FrameworkDetection {
|
|
108
|
+
return {
|
|
109
|
+
solid: FRAMEWORK_PATTERNS.solid.some(pattern => content.includes(pattern)),
|
|
110
|
+
vue: FRAMEWORK_PATTERNS.vue.some(pattern => content.includes(pattern)),
|
|
111
|
+
svelte: FRAMEWORK_PATTERNS.svelte.some(pattern => content.includes(pattern)),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Validates that imports are allowed for the detected framework
|
|
117
|
+
*/
|
|
118
|
+
function validateFrameworkImports(componentPath: string, content: string, detectedFramework: string): string[] {
|
|
119
|
+
const warnings: string[] = [];
|
|
120
|
+
|
|
121
|
+
// Extract import statements — match the quoted module specifier at the end of any import line
|
|
122
|
+
const importRegex = /^import\s[^'"]*['"]([^'"]+)['"]/gm;
|
|
123
|
+
const imports: string[] = [];
|
|
124
|
+
|
|
125
|
+
let match;
|
|
126
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
127
|
+
imports.push(match[1]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Override framework detection based on naming convention
|
|
131
|
+
let actualFramework = detectedFramework;
|
|
132
|
+
if (componentPath.includes('.solid.')) {
|
|
133
|
+
actualFramework = 'solid';
|
|
134
|
+
} else if (componentPath.includes('.preact.')) {
|
|
135
|
+
actualFramework = 'preact';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Check for problematic cross-framework imports
|
|
139
|
+
const problematicImports = new Map<string, string[]>([
|
|
140
|
+
['preact', ['solid-js', 'solid-js/web', 'vue', 'svelte']],
|
|
141
|
+
['solid', ['preact', 'preact-render-to-string', 'vue', 'svelte']],
|
|
142
|
+
['vue', ['preact', 'solid-js', 'svelte']],
|
|
143
|
+
['svelte', ['preact', 'solid-js', 'vue']],
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
const forbidden = problematicImports.get(actualFramework) || [];
|
|
147
|
+
|
|
148
|
+
for (const importPath of imports) {
|
|
149
|
+
for (const forbiddenPattern of forbidden) {
|
|
150
|
+
if (importPath.startsWith(forbiddenPattern)) {
|
|
151
|
+
warnings.push(
|
|
152
|
+
`Cross-framework import detected: ${actualFramework} component (${componentPath}) importing ${importPath}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return warnings;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function applyStrategyToTag(fullMatch: string, strategy: RenderStrategy): string {
|
|
162
|
+
if (strategy.type === 'ssr-only') {
|
|
163
|
+
return fullMatch
|
|
164
|
+
.replaceAll(/data-hydrate="[^"]*"\s*/g, '')
|
|
165
|
+
.replace('>', ` data-render-strategy="${strategy.type}" data-ssr-reason="${strategy.reason}">`);
|
|
166
|
+
}
|
|
167
|
+
return fullMatch.replace('>', ` data-render-strategy="${strategy.type}" data-hydrate-reason="${strategy.reason}">`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Analyzes components in rendered content and adds rendering strategy attributes
|
|
172
|
+
* using the intelligent component detection system with import validation
|
|
173
|
+
*/
|
|
174
|
+
async function enhanceContentWithRenderingStrategy(
|
|
175
|
+
content: string,
|
|
176
|
+
renderOptions: ComponentRenderOptions = {},
|
|
177
|
+
): Promise<string> {
|
|
178
|
+
const hydrateRegex = /(<[^>]*data-hydrate="([^"]*)"[^>]*>)/g;
|
|
179
|
+
let enhancedContent = content;
|
|
180
|
+
const matches = Array.from(content.matchAll(hydrateRegex));
|
|
181
|
+
|
|
182
|
+
for (const match of matches) {
|
|
183
|
+
const [fullMatch, _elementTag, componentPath] = match;
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
if (fullMatch.includes('data-render-strategy')) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const strategy = await determineRenderStrategy(componentPath, renderOptions);
|
|
191
|
+
await validateComponentImports(componentPath, renderOptions);
|
|
192
|
+
|
|
193
|
+
enhancedContent = enhancedContent.replace(fullMatch, applyStrategyToTag(fullMatch, strategy));
|
|
194
|
+
|
|
195
|
+
if (renderOptions.logDecisions === true) {
|
|
196
|
+
console.log(`[SSR Strategy] ${componentPath} -> ${strategy.type.toUpperCase()}: ${strategy.reason}`);
|
|
197
|
+
if (strategy.warnings && strategy.warnings.length > 0 && !renderOptions.suppressWarnings) {
|
|
198
|
+
strategy.warnings.forEach(warning => console.warn(`[SSR Warning] ${componentPath}: ${warning}`));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
console.warn(`Failed to analyze component ${componentPath}:`, error);
|
|
203
|
+
const enhancedTag = fullMatch.replace('>', ` data-render-strategy="hydrate" data-error="analysis-failed">`);
|
|
204
|
+
enhancedContent = enhancedContent.replace(fullMatch, enhancedTag);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return enhancedContent;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Validates component imports to prevent cross-framework contamination
|
|
213
|
+
*/
|
|
214
|
+
async function validateComponentImports(
|
|
215
|
+
componentPath: string,
|
|
216
|
+
renderOptions: ComponentRenderOptions = {},
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
try {
|
|
219
|
+
// Try to read and analyze the component file
|
|
220
|
+
let componentContent: string | undefined;
|
|
221
|
+
let resolvedPath = componentPath;
|
|
222
|
+
|
|
223
|
+
// Handle different path formats
|
|
224
|
+
if (componentPath.startsWith('/')) {
|
|
225
|
+
resolvedPath = componentPath.substring(1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Try multiple path variations
|
|
229
|
+
const pathVariations = [
|
|
230
|
+
resolvedPath,
|
|
231
|
+
`examples/${resolvedPath.split('/').pop()}`,
|
|
232
|
+
`src/islands/${resolvedPath.split('/').pop()}`,
|
|
233
|
+
`islands/${resolvedPath.split('/').pop()}`,
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
let foundPath = '';
|
|
237
|
+
for (const pathVariation of pathVariations) {
|
|
238
|
+
try {
|
|
239
|
+
componentContent = await readFile(pathVariation, 'utf-8');
|
|
240
|
+
foundPath = pathVariation;
|
|
241
|
+
break;
|
|
242
|
+
} catch {
|
|
243
|
+
// Continue to next path variation
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!foundPath || !componentContent) {
|
|
249
|
+
// Component file not found, skip validation
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Detect framework from content patterns
|
|
254
|
+
const frameworks = detectFrameworks(componentContent);
|
|
255
|
+
let detectedFramework = 'preact'; // default
|
|
256
|
+
|
|
257
|
+
if (frameworks.solid) detectedFramework = 'solid';
|
|
258
|
+
else if (frameworks.vue) detectedFramework = 'vue';
|
|
259
|
+
else if (frameworks.svelte) detectedFramework = 'svelte';
|
|
260
|
+
|
|
261
|
+
// Validate imports for this framework
|
|
262
|
+
const importWarnings = validateFrameworkImports(foundPath, componentContent, detectedFramework);
|
|
263
|
+
|
|
264
|
+
// Log import validation warnings
|
|
265
|
+
if (importWarnings.length > 0 && !renderOptions.suppressWarnings) {
|
|
266
|
+
importWarnings.forEach(warning => console.warn(`[Import Validation] ${warning}`));
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
// Validation failed, but don't break the rendering process
|
|
270
|
+
if (renderOptions.logDecisions !== false) {
|
|
271
|
+
console.warn(`Import validation failed for ${componentPath}:`, error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Determines the render strategy for a component using intelligent detection
|
|
278
|
+
*/
|
|
279
|
+
async function determineRenderStrategy(
|
|
280
|
+
componentPath: string,
|
|
281
|
+
options: ComponentRenderOptions = {},
|
|
282
|
+
): Promise<RenderStrategy> {
|
|
283
|
+
// Handle explicit SSR-only override
|
|
284
|
+
if (options.forceSSROnly) {
|
|
285
|
+
return {
|
|
286
|
+
type: 'ssr-only',
|
|
287
|
+
reason: 'Explicitly configured for SSR-only rendering',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Quick heuristic checks for known naming patterns that explicitly indicate SSR-only
|
|
292
|
+
if (componentPath.includes('NoHydrate') || componentPath.includes('Static') || componentPath.includes('SSROnly')) {
|
|
293
|
+
return {
|
|
294
|
+
type: 'ssr-only',
|
|
295
|
+
reason: 'Component name explicitly indicates SSR-only rendering',
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// If script detection is disabled, default to hydration
|
|
300
|
+
if (options.detectScripts === false) {
|
|
301
|
+
return {
|
|
302
|
+
type: 'hydrate',
|
|
303
|
+
reason: 'Script detection disabled, defaulting to hydration',
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
// Try to read and analyze the component file
|
|
309
|
+
let componentContent: string;
|
|
310
|
+
let resolvedPath = componentPath;
|
|
311
|
+
|
|
312
|
+
// Handle different path formats
|
|
313
|
+
if (componentPath.startsWith('/')) {
|
|
314
|
+
resolvedPath = componentPath.substring(1);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Try multiple path variations
|
|
318
|
+
const pathVariations = [
|
|
319
|
+
resolvedPath,
|
|
320
|
+
`examples/${resolvedPath.split('/').pop()}`,
|
|
321
|
+
`src/islands/${resolvedPath.split('/').pop()}`,
|
|
322
|
+
`islands/${resolvedPath.split('/').pop()}`,
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
let analysisResult = null;
|
|
326
|
+
for (const pathVariation of pathVariations) {
|
|
327
|
+
try {
|
|
328
|
+
componentContent = await readFile(pathVariation, 'utf-8');
|
|
329
|
+
|
|
330
|
+
// Perform intelligent component analysis
|
|
331
|
+
const analyzerOptions: AnalyzerOptions = {
|
|
332
|
+
forceSSROnly: options.forceSSROnly,
|
|
333
|
+
detectScripts: options.detectScripts,
|
|
334
|
+
suppressWarnings: options.suppressWarnings,
|
|
335
|
+
logDecisions: false, // We'll handle logging at the SSR level
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
analysisResult = analyzeComponentContent(pathVariation, componentContent, analyzerOptions);
|
|
339
|
+
break;
|
|
340
|
+
} catch {
|
|
341
|
+
// Continue to next path variation
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (analysisResult) {
|
|
347
|
+
return {
|
|
348
|
+
type: analysisResult.decision.shouldHydrate ? 'hydrate' : 'ssr-only',
|
|
349
|
+
reason: analysisResult.decision.reason,
|
|
350
|
+
warnings: analysisResult.decision.warnings,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// If we can't read the file, fall back to extension-based heuristics
|
|
355
|
+
return determineStrategyFromPath(componentPath);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
console.warn(`Component analysis failed for ${componentPath}:`, error);
|
|
358
|
+
return {
|
|
359
|
+
type: 'ssr-only',
|
|
360
|
+
reason: 'Analysis failed, defaulting to SSR-only for safety',
|
|
361
|
+
warnings: [`Component analysis error: ${error instanceof Error ? error.message : String(error)}`],
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Fallback strategy determination based on file path and naming conventions
|
|
368
|
+
*/
|
|
369
|
+
function determineStrategyFromPath(componentPath: string): RenderStrategy {
|
|
370
|
+
// Check file extension patterns - but default to SSR-only unless we can confirm hydration is needed
|
|
371
|
+
if (
|
|
372
|
+
componentPath.endsWith('.vue') ||
|
|
373
|
+
componentPath.endsWith('.svelte') ||
|
|
374
|
+
componentPath.endsWith('.tsx') ||
|
|
375
|
+
componentPath.endsWith('.jsx')
|
|
376
|
+
) {
|
|
377
|
+
// Framework components default to SSR-only unless they have explicit hydrate functions
|
|
378
|
+
return {
|
|
379
|
+
type: 'ssr-only',
|
|
380
|
+
reason: 'Framework component detected, defaulting to SSR-only (hydration requires explicit hydrate function)',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Unknown file type, default to SSR-only for safety
|
|
385
|
+
return {
|
|
386
|
+
type: 'ssr-only',
|
|
387
|
+
reason: 'Unknown component type, defaulting to SSR-only for safety',
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function generateMetaTags(options: Partial<RenderOptions>): string {
|
|
392
|
+
return options.meta?.map(({ name, content }) => `<meta name="${name}" content="${content}">`).join('\n ') || '';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function generateStyleTags(options: Partial<RenderOptions>): string {
|
|
396
|
+
const styleTags = options.styles?.map(href => `<link rel="stylesheet" href="${href}">`).join('\n ') || '';
|
|
397
|
+
|
|
398
|
+
// Note: CSS from all frameworks (including Svelte) is now handled by the universal CSS collector
|
|
399
|
+
// which is injected in generateHead() via getUniversalCSSForHead()
|
|
400
|
+
|
|
401
|
+
return styleTags;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function generateScriptTags(options: Partial<RenderOptions>): string {
|
|
405
|
+
return (
|
|
406
|
+
options.scripts
|
|
407
|
+
?.map(script => {
|
|
408
|
+
if (typeof script === 'string') {
|
|
409
|
+
return `<script src="${script}" defer></script>`;
|
|
410
|
+
}
|
|
411
|
+
const attrs = script.src ? `src="${script.src}"` : '';
|
|
412
|
+
const type = script.type ? `type="${script.type}"` : '';
|
|
413
|
+
const content = script.content || '';
|
|
414
|
+
return `<script ${attrs} ${type}>${content}</script>`;
|
|
415
|
+
})
|
|
416
|
+
.join('\n ') || ''
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function generateClientScripts(isDev: boolean, _frameworks: FrameworkDetection): string {
|
|
421
|
+
const baseScript = isDev ? '/src/client/main.js' : '/dist/client.js';
|
|
422
|
+
|
|
423
|
+
return `
|
|
424
|
+
<script type="module" src="${baseScript}"></script>`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function generateHMRScript(isDev: boolean, viteHmrPort?: number): string {
|
|
428
|
+
return isDev && viteHmrPort
|
|
429
|
+
? `
|
|
430
|
+
<script type="module">
|
|
431
|
+
if (import.meta.hot) {
|
|
432
|
+
import.meta.hot.accept();
|
|
433
|
+
}
|
|
434
|
+
</script>`
|
|
435
|
+
: '';
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Escape HTML special characters for safe interpolation into HTML */
|
|
439
|
+
function escapeHtml(str: string): string {
|
|
440
|
+
return str
|
|
441
|
+
.replaceAll('&', '&')
|
|
442
|
+
.replaceAll('<', '<')
|
|
443
|
+
.replaceAll('>', '>')
|
|
444
|
+
.replaceAll('"', '"')
|
|
445
|
+
.replaceAll("'", ''');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function generateHead(options: Partial<RenderOptions>, frameworks: FrameworkDetection, viteHmrPort?: number): string {
|
|
449
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
450
|
+
|
|
451
|
+
const metaTags = generateMetaTags(options);
|
|
452
|
+
const styleTags = generateStyleTags(options);
|
|
453
|
+
const scriptTags = generateScriptTags(options);
|
|
454
|
+
const clientScripts = generateClientScripts(isDev, frameworks);
|
|
455
|
+
const hmrScript = generateHMRScript(isDev, viteHmrPort);
|
|
456
|
+
|
|
457
|
+
// Collect CSS from all framework integrations
|
|
458
|
+
const universalCSS = getUniversalCSSForHead(true); // Clear after collecting
|
|
459
|
+
|
|
460
|
+
// Collect head content (hydration scripts, etc.) from all framework integrations
|
|
461
|
+
const universalHead = getUniversalHeadForInjection(true); // Clear after collecting
|
|
462
|
+
|
|
463
|
+
// Generate importmap for browser to resolve integration packages
|
|
464
|
+
const importMap = `
|
|
465
|
+
<script type="importmap">
|
|
466
|
+
{
|
|
467
|
+
"imports": {
|
|
468
|
+
"@useavalon/preact/client": "/packages/integrations/preact/client/index.ts",
|
|
469
|
+
"@useavalon/vue/client": "/packages/integrations/vue/client/index.ts",
|
|
470
|
+
"@useavalon/solid/client": "/packages/integrations/solid/client/index.ts",
|
|
471
|
+
"@useavalon/svelte/client": "/packages/integrations/svelte/client/index.ts",
|
|
472
|
+
"@useavalon/shared": "/packages/integrations/core/types.ts"
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
</script>`;
|
|
476
|
+
|
|
477
|
+
return `
|
|
478
|
+
<head>
|
|
479
|
+
<meta charset="utf-8">
|
|
480
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
481
|
+
${metaTags}
|
|
482
|
+
<title>${escapeHtml(String(options.title || 'Avalon App'))}</title>
|
|
483
|
+
${importMap}
|
|
484
|
+
${styleTags}
|
|
485
|
+
${universalCSS}
|
|
486
|
+
${universalHead}
|
|
487
|
+
${scriptTags}${clientScripts}${hmrScript}
|
|
488
|
+
</head>`.trim();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export async function renderToHtml(
|
|
492
|
+
routeConfig: RouteConfig,
|
|
493
|
+
defaultOptions: Partial<RenderOptions> = {},
|
|
494
|
+
viteHmrPort?: number,
|
|
495
|
+
renderOptions: ComponentRenderOptions = {},
|
|
496
|
+
): Promise<string> {
|
|
497
|
+
try {
|
|
498
|
+
let content: string;
|
|
499
|
+
let frameworks: FrameworkDetection;
|
|
500
|
+
|
|
501
|
+
if (renderOptions.forceSSROnly === true) {
|
|
502
|
+
const componentResult = routeConfig.component();
|
|
503
|
+
const resolvedComponent = componentResult instanceof Promise ? await componentResult : componentResult;
|
|
504
|
+
content = preactRenderToString(resolvedComponent);
|
|
505
|
+
frameworks = detectFrameworks(content);
|
|
506
|
+
} else {
|
|
507
|
+
({ content, frameworks } = await renderWithIsolationOrFallback(routeConfig, renderOptions));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
content = await enhanceContentWithRenderingStrategy(content, renderOptions);
|
|
511
|
+
const options = { ...defaultOptions, ...routeConfig.options };
|
|
512
|
+
const head = generateHead(options, frameworks, viteHmrPort);
|
|
513
|
+
|
|
514
|
+
return `<!DOCTYPE html>\n<html lang="en">\n${head}\n<body>\n${content}\n</body>\n</html>`;
|
|
515
|
+
} catch (error) {
|
|
516
|
+
console.error('Error rendering component:', error);
|
|
517
|
+
throw new Error('Failed to render component');
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Render to HTML with layout system support
|
|
523
|
+
*/
|
|
524
|
+
export async function renderToHtmlWithLayouts(
|
|
525
|
+
routeConfig: RouteConfig,
|
|
526
|
+
layoutResolver: EnhancedLayoutResolver,
|
|
527
|
+
layoutContext: LayoutContext,
|
|
528
|
+
routePath: string,
|
|
529
|
+
defaultOptions: Partial<RenderOptions> = {},
|
|
530
|
+
viteHmrPort?: number,
|
|
531
|
+
renderOptions: ComponentRenderOptions = {},
|
|
532
|
+
): Promise<string> {
|
|
533
|
+
try {
|
|
534
|
+
const routeConfigExtended = routeConfig as RouteConfig & Partial<PageModule>;
|
|
535
|
+
const pageModule: PageModule = {
|
|
536
|
+
default: routeConfig.component,
|
|
537
|
+
layoutConfig: routeConfigExtended.layoutConfig,
|
|
538
|
+
loader: routeConfigExtended.loader,
|
|
539
|
+
frontmatter: routeConfig.frontmatter,
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const resolvedLayout = await layoutResolver.resolveAndRender(routePath, pageModule, layoutContext);
|
|
543
|
+
|
|
544
|
+
if (resolvedLayout.handlers.length === 0) {
|
|
545
|
+
return await renderToHtml(routeConfig, defaultOptions, viteHmrPort, renderOptions);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const pageContent = await renderPageContent(routeConfig, routePath, renderOptions);
|
|
549
|
+
const wrappedContent = await applyLayoutChain(pageContent, resolvedLayout, pageModule, layoutContext, routePath);
|
|
550
|
+
const enhancedContent = await enhanceContentWithRenderingStrategy(wrappedContent, renderOptions);
|
|
551
|
+
|
|
552
|
+
return assembleLayoutHtml(enhancedContent, routeConfig, defaultOptions, viteHmrPort);
|
|
553
|
+
} catch (error) {
|
|
554
|
+
console.error('Error rendering component with layouts:', error);
|
|
555
|
+
try {
|
|
556
|
+
return await renderToHtml(routeConfig, defaultOptions, viteHmrPort, renderOptions);
|
|
557
|
+
} catch (fallbackError) {
|
|
558
|
+
console.error('Fallback rendering also failed:', fallbackError);
|
|
559
|
+
throw new Error('Failed to render component with layouts and fallback failed');
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function assembleLayoutHtml(
|
|
565
|
+
enhancedContent: string,
|
|
566
|
+
routeConfig: RouteConfig,
|
|
567
|
+
defaultOptions: Partial<RenderOptions>,
|
|
568
|
+
viteHmrPort: number | undefined,
|
|
569
|
+
): string {
|
|
570
|
+
const isCompleteDoc =
|
|
571
|
+
enhancedContent.trim().startsWith('<!DOCTYPE html>') || enhancedContent.trim().startsWith('<html');
|
|
572
|
+
if (isCompleteDoc) {
|
|
573
|
+
return injectClientScript(enhancedContent);
|
|
574
|
+
}
|
|
575
|
+
const frameworks = detectFrameworks(enhancedContent);
|
|
576
|
+
const options = { ...defaultOptions, ...routeConfig.options };
|
|
577
|
+
const head = generateHead(options, frameworks, viteHmrPort);
|
|
578
|
+
return injectClientScript(`<!DOCTYPE html>\n<html lang="en">\n${head}\n<body>\n${enhancedContent}\n</body>\n</html>`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Streaming render options
|
|
583
|
+
*/
|
|
584
|
+
export interface StreamingRenderOptions extends ComponentRenderOptions {
|
|
585
|
+
/**
|
|
586
|
+
* Callback when the shell (initial HTML) is ready to stream
|
|
587
|
+
*/
|
|
588
|
+
onShellReady?: () => void;
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Callback when an error occurs before streaming starts
|
|
592
|
+
*/
|
|
593
|
+
onShellError?: (error: Error) => void;
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Callback when all content has been rendered
|
|
597
|
+
*/
|
|
598
|
+
onAllReady?: () => void;
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Callback for any error during rendering
|
|
602
|
+
*/
|
|
603
|
+
onError?: (error: Error) => void;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Renders route component content to an HTML string, using isolated rendering with fallback.
|
|
608
|
+
*/
|
|
609
|
+
async function renderStreamContent(
|
|
610
|
+
routeConfig: RouteConfig,
|
|
611
|
+
defaultOptions: Partial<RenderOptions>,
|
|
612
|
+
viteHmrPort: number | undefined,
|
|
613
|
+
renderOptions: StreamingRenderOptions,
|
|
614
|
+
): Promise<{ head: string; content: string }> {
|
|
615
|
+
let content: string;
|
|
616
|
+
let frameworks: FrameworkDetection;
|
|
617
|
+
|
|
618
|
+
if (renderOptions.forceSSROnly === true) {
|
|
619
|
+
const componentResult = routeConfig.component();
|
|
620
|
+
const resolvedComponent = componentResult instanceof Promise ? await componentResult : componentResult;
|
|
621
|
+
content = preactRenderToString(resolvedComponent);
|
|
622
|
+
frameworks = detectFrameworks(content);
|
|
623
|
+
} else {
|
|
624
|
+
({ content, frameworks } = await renderWithIsolationOrFallback(routeConfig, renderOptions));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
content = await enhanceContentWithRenderingStrategy(content, renderOptions);
|
|
628
|
+
const options = { ...defaultOptions, ...routeConfig.options };
|
|
629
|
+
const head = generateHead(options, frameworks, viteHmrPort);
|
|
630
|
+
return { head, content };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function renderWithIsolationOrFallback(
|
|
634
|
+
routeConfig: RouteConfig,
|
|
635
|
+
renderOptions: StreamingRenderOptions,
|
|
636
|
+
): Promise<{ content: string; frameworks: FrameworkDetection }> {
|
|
637
|
+
try {
|
|
638
|
+
const renderer = getIsolatedRenderer();
|
|
639
|
+
const isolatedResult = await renderer.renderWithIsolation({
|
|
640
|
+
componentPath: 'route-component',
|
|
641
|
+
component: routeConfig.component,
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
if (!isolatedResult.success) {
|
|
645
|
+
throw new Error(`Isolated rendering failed: ${isolatedResult.errors.join(', ')}`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (isolatedResult.warnings.length > 0 && !renderOptions.suppressWarnings) {
|
|
649
|
+
isolatedResult.warnings.forEach(w => console.warn(`[SSR Isolation] ${w}`));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return { content: isolatedResult.html, frameworks: detectFrameworks(isolatedResult.html) };
|
|
653
|
+
} catch (isolatedError) {
|
|
654
|
+
console.warn('[SSR] Isolated rendering failed, falling back to standard rendering:', isolatedError);
|
|
655
|
+
const componentResult = routeConfig.component();
|
|
656
|
+
const resolvedComponent = componentResult instanceof Promise ? await componentResult : componentResult;
|
|
657
|
+
const content = preactRenderToString(resolvedComponent);
|
|
658
|
+
return { content, frameworks: detectFrameworks(content) };
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function handleStreamError(
|
|
663
|
+
err: Error,
|
|
664
|
+
shellSent: boolean,
|
|
665
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
666
|
+
encoder: TextEncoder,
|
|
667
|
+
renderOptions: StreamingRenderOptions,
|
|
668
|
+
label = 'Streaming Error',
|
|
669
|
+
componentId = 'route-component',
|
|
670
|
+
): void {
|
|
671
|
+
console.error(`[${label}]`, {
|
|
672
|
+
message: err.message,
|
|
673
|
+
stack: err.stack,
|
|
674
|
+
shellSent,
|
|
675
|
+
timestamp: new Date().toISOString(),
|
|
676
|
+
});
|
|
677
|
+
renderOptions.onError?.(err);
|
|
678
|
+
|
|
679
|
+
if (shellSent) {
|
|
680
|
+
console.log(`[${label}] Mid-stream error detected, injecting error boundary`);
|
|
681
|
+
try {
|
|
682
|
+
controller.enqueue(encoder.encode(generateMidStreamErrorBoundary(err, componentId)));
|
|
683
|
+
controller.enqueue(encoder.encode('\n</body>\n</html>'));
|
|
684
|
+
} catch (injectError) {
|
|
685
|
+
console.error(`[${label}] Failed to inject error boundary:`, injectError);
|
|
686
|
+
}
|
|
687
|
+
} else {
|
|
688
|
+
renderOptions.onShellError?.(err);
|
|
689
|
+
controller.enqueue(encoder.encode(generateErrorPage(err)));
|
|
690
|
+
}
|
|
691
|
+
controller.close();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Renders a route to a streaming HTML response
|
|
696
|
+
* This is the streaming equivalent of renderToHtml()
|
|
697
|
+
*/
|
|
698
|
+
export async function renderToHtmlStream(
|
|
699
|
+
routeConfig: RouteConfig,
|
|
700
|
+
defaultOptions: Partial<RenderOptions> = {},
|
|
701
|
+
viteHmrPort?: number,
|
|
702
|
+
renderOptions: StreamingRenderOptions = {},
|
|
703
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
704
|
+
const encoder = new TextEncoder();
|
|
705
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
706
|
+
let shellSent = false;
|
|
707
|
+
|
|
708
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
709
|
+
async start(ctrl) {
|
|
710
|
+
controller = ctrl;
|
|
711
|
+
try {
|
|
712
|
+
const { head, content } = await renderStreamContent(routeConfig, defaultOptions, viteHmrPort, renderOptions);
|
|
713
|
+
|
|
714
|
+
controller.enqueue(encoder.encode(`<!DOCTYPE html>\n<html lang="en">\n${head}\n<body>\n`));
|
|
715
|
+
shellSent = true;
|
|
716
|
+
renderOptions.onShellReady?.();
|
|
717
|
+
|
|
718
|
+
controller.enqueue(encoder.encode(content));
|
|
719
|
+
controller.enqueue(encoder.encode('\n</body>\n</html>'));
|
|
720
|
+
renderOptions.onAllReady?.();
|
|
721
|
+
controller.close();
|
|
722
|
+
} catch (error) {
|
|
723
|
+
handleStreamError(
|
|
724
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
725
|
+
shellSent,
|
|
726
|
+
controller,
|
|
727
|
+
encoder,
|
|
728
|
+
renderOptions,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
},
|
|
732
|
+
cancel() {
|
|
733
|
+
try {
|
|
734
|
+
controller?.close();
|
|
735
|
+
} catch {
|
|
736
|
+
/* already closed */
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
return stream;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Renders a route with layouts to a streaming HTML response
|
|
746
|
+
* This is the streaming equivalent of renderToHtmlWithLayouts()
|
|
747
|
+
*/
|
|
748
|
+
export async function renderToHtmlStreamWithLayouts(
|
|
749
|
+
routeConfig: RouteConfig,
|
|
750
|
+
layoutResolver: EnhancedLayoutResolver,
|
|
751
|
+
layoutContext: LayoutContext,
|
|
752
|
+
routePath: string,
|
|
753
|
+
defaultOptions: Partial<RenderOptions> = {},
|
|
754
|
+
viteHmrPort?: number,
|
|
755
|
+
renderOptions: StreamingRenderOptions = {},
|
|
756
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
757
|
+
const encoder = new TextEncoder();
|
|
758
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
759
|
+
let shellSent = false;
|
|
760
|
+
|
|
761
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
762
|
+
async start(ctrl) {
|
|
763
|
+
controller = ctrl;
|
|
764
|
+
try {
|
|
765
|
+
const routeConfigExtended = routeConfig as RouteConfig & Partial<PageModule>;
|
|
766
|
+
const pageModule: PageModule = {
|
|
767
|
+
default: routeConfig.component,
|
|
768
|
+
layoutConfig: routeConfigExtended.layoutConfig,
|
|
769
|
+
loader: routeConfigExtended.loader,
|
|
770
|
+
frontmatter: routeConfig.frontmatter,
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
const resolvedLayout = await layoutResolver.resolveAndRender(routePath, pageModule, layoutContext);
|
|
774
|
+
|
|
775
|
+
if (resolvedLayout.handlers.length === 0) {
|
|
776
|
+
const fallbackStream = await renderToHtmlStream(routeConfig, defaultOptions, viteHmrPort, renderOptions);
|
|
777
|
+
const reader = fallbackStream.getReader();
|
|
778
|
+
try {
|
|
779
|
+
while (true) {
|
|
780
|
+
const { done, value } = await reader.read();
|
|
781
|
+
if (done) break;
|
|
782
|
+
controller.enqueue(value);
|
|
783
|
+
}
|
|
784
|
+
} finally {
|
|
785
|
+
reader.releaseLock();
|
|
786
|
+
}
|
|
787
|
+
controller.close();
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const pageContent = await renderPageContent(routeConfig, routePath, renderOptions);
|
|
792
|
+
const wrappedContent = await applyLayoutChain(
|
|
793
|
+
pageContent,
|
|
794
|
+
resolvedLayout,
|
|
795
|
+
pageModule,
|
|
796
|
+
layoutContext,
|
|
797
|
+
routePath,
|
|
798
|
+
);
|
|
799
|
+
const enhancedContent = await enhanceContentWithRenderingStrategy(wrappedContent, renderOptions);
|
|
800
|
+
|
|
801
|
+
const isCompleteDoc =
|
|
802
|
+
enhancedContent.trim().startsWith('<!DOCTYPE html>') || enhancedContent.trim().startsWith('<html');
|
|
803
|
+
if (isCompleteDoc) {
|
|
804
|
+
const finalHtml = injectClientScript(enhancedContent);
|
|
805
|
+
controller.enqueue(encoder.encode(finalHtml));
|
|
806
|
+
shellSent = true;
|
|
807
|
+
renderOptions.onShellReady?.();
|
|
808
|
+
renderOptions.onAllReady?.();
|
|
809
|
+
controller.close();
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const frameworks = detectFrameworks(enhancedContent);
|
|
814
|
+
const options = { ...defaultOptions, ...routeConfig.options };
|
|
815
|
+
const head = generateHead(options, frameworks, viteHmrPort);
|
|
816
|
+
|
|
817
|
+
controller.enqueue(encoder.encode(`<!DOCTYPE html>\n<html lang="en">\n${head}\n<body>\n`));
|
|
818
|
+
shellSent = true;
|
|
819
|
+
renderOptions.onShellReady?.();
|
|
820
|
+
|
|
821
|
+
controller.enqueue(encoder.encode(enhancedContent));
|
|
822
|
+
controller.enqueue(encoder.encode('\n</body>\n</html>'));
|
|
823
|
+
renderOptions.onAllReady?.();
|
|
824
|
+
controller.close();
|
|
825
|
+
} catch (error) {
|
|
826
|
+
handleStreamError(
|
|
827
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
828
|
+
shellSent,
|
|
829
|
+
controller,
|
|
830
|
+
encoder,
|
|
831
|
+
renderOptions,
|
|
832
|
+
'Streaming Error with Layouts',
|
|
833
|
+
`layout-${routePath}`,
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
},
|
|
837
|
+
cancel() {
|
|
838
|
+
try {
|
|
839
|
+
controller?.close();
|
|
840
|
+
} catch {
|
|
841
|
+
/* already closed */
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
return stream;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function renderPageContent(
|
|
850
|
+
routeConfig: RouteConfig,
|
|
851
|
+
routePath: string,
|
|
852
|
+
renderOptions: StreamingRenderOptions,
|
|
853
|
+
): Promise<string> {
|
|
854
|
+
if (renderOptions.forceSSROnly === true) {
|
|
855
|
+
const componentResult = routeConfig.component();
|
|
856
|
+
const resolved = componentResult instanceof Promise ? await componentResult : componentResult;
|
|
857
|
+
return preactRenderToString(resolved);
|
|
858
|
+
}
|
|
859
|
+
try {
|
|
860
|
+
const renderer = getIsolatedRenderer();
|
|
861
|
+
const isolatedResult = await renderer.renderWithIsolation({
|
|
862
|
+
componentPath: routePath,
|
|
863
|
+
component: routeConfig.component,
|
|
864
|
+
});
|
|
865
|
+
if (!isolatedResult.success) throw new Error(`Isolated rendering failed: ${isolatedResult.errors.join(', ')}`);
|
|
866
|
+
if (isolatedResult.warnings.length > 0 && !renderOptions.suppressWarnings) {
|
|
867
|
+
isolatedResult.warnings.forEach(w => console.warn(`[SSR Isolation] ${w}`));
|
|
868
|
+
}
|
|
869
|
+
return isolatedResult.html;
|
|
870
|
+
} catch (isolatedError) {
|
|
871
|
+
console.warn('[SSR] Isolated page rendering failed, falling back to standard rendering:', isolatedError);
|
|
872
|
+
const componentResult = routeConfig.component();
|
|
873
|
+
const resolved = componentResult instanceof Promise ? await componentResult : componentResult;
|
|
874
|
+
return preactRenderToString(resolved);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async function applyLayoutChain(
|
|
879
|
+
pageContent: string,
|
|
880
|
+
resolvedLayout: Awaited<ReturnType<EnhancedLayoutResolver['resolveAndRender']>>,
|
|
881
|
+
pageModule: PageModule,
|
|
882
|
+
layoutContext: LayoutContext,
|
|
883
|
+
routePath: string,
|
|
884
|
+
): Promise<string> {
|
|
885
|
+
// Build a composed JSX tree: outermost layout wraps inner layouts wraps page content.
|
|
886
|
+
// The innermost layout receives the page content as actual JSX children,
|
|
887
|
+
// eliminating the need for dangerouslySetInnerHTML in layout components.
|
|
888
|
+
//
|
|
889
|
+
// We start from the innermost layout and work outward, building a nested
|
|
890
|
+
// JSX element tree. The final tree is rendered to HTML in one pass.
|
|
891
|
+
|
|
892
|
+
// Start with the page content as a raw-HTML JSX node.
|
|
893
|
+
// Preact's `dangerouslySetInnerHTML` is used here at the framework level
|
|
894
|
+
// so layout authors never need to use it themselves.
|
|
895
|
+
let tree: JSX.Element = h('avalon-page-content', { dangerouslySetInnerHTML: { __html: pageContent } });
|
|
896
|
+
|
|
897
|
+
// Track whether the final output was already rendered to HTML by an async
|
|
898
|
+
// layout (e.g. the root layout that produces a full <html> document).
|
|
899
|
+
let preRenderedHtml: string | null = null;
|
|
900
|
+
|
|
901
|
+
// Wrap from innermost to outermost layout
|
|
902
|
+
for (let i = resolvedLayout.handlers.length - 1; i >= 0; i--) {
|
|
903
|
+
const handler = resolvedLayout.handlers[i];
|
|
904
|
+
const layoutData = resolvedLayout.dataLoaders[i] ? await resolvedLayout.dataLoaders[i](layoutContext) : {};
|
|
905
|
+
const layoutProps = {
|
|
906
|
+
data: layoutData,
|
|
907
|
+
frontmatter: pageModule.frontmatter || {},
|
|
908
|
+
route: { path: routePath, params: layoutContext.params, query: layoutContext.query },
|
|
909
|
+
} as Record<string, unknown>;
|
|
910
|
+
|
|
911
|
+
// Layout components may be async when they contain island transforms
|
|
912
|
+
// (the page-island-transform plugin injects `await renderIsland(...)` calls).
|
|
913
|
+
// Preact's renderToString doesn't support async components, so we
|
|
914
|
+
// pre-resolve async layouts here before composing the JSX tree.
|
|
915
|
+
const component = handler.component as any;
|
|
916
|
+
const result = component({ ...layoutProps, children: tree });
|
|
917
|
+
if (result instanceof Promise) {
|
|
918
|
+
const resolved = await result;
|
|
919
|
+
const html = preactRenderToString(resolved);
|
|
920
|
+
// If this is the outermost layout (i === 0) or it produced a full
|
|
921
|
+
// HTML document, keep the rendered string directly so downstream
|
|
922
|
+
// code (assembleLayoutHtml) can detect the <html> tag and preserve
|
|
923
|
+
// the layout's own <head> (which may contain stylesheet links like
|
|
924
|
+
// syntax-highlighting.css).
|
|
925
|
+
if (i === 0) {
|
|
926
|
+
preRenderedHtml = html;
|
|
927
|
+
} else {
|
|
928
|
+
tree = h('avalon-layout-fragment', { dangerouslySetInnerHTML: { __html: html } });
|
|
929
|
+
}
|
|
930
|
+
} else {
|
|
931
|
+
// Synchronous layout — use standard Preact composition
|
|
932
|
+
tree = h(component, layoutProps, tree);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// If the outermost layout was async and already rendered, return directly.
|
|
937
|
+
if (preRenderedHtml !== null) {
|
|
938
|
+
return preRenderedHtml;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Render the entire composed tree to HTML in one pass
|
|
942
|
+
try {
|
|
943
|
+
const renderer = getIsolatedRenderer();
|
|
944
|
+
const result = await renderer.renderWithIsolation({
|
|
945
|
+
componentPath: `layout-chain-${routePath}`,
|
|
946
|
+
component: () => tree,
|
|
947
|
+
});
|
|
948
|
+
if (result.success) {
|
|
949
|
+
return result.html;
|
|
950
|
+
}
|
|
951
|
+
} catch {
|
|
952
|
+
// Fall through to standard rendering
|
|
953
|
+
}
|
|
954
|
+
return preactRenderToString(tree);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Generates an error page for streaming errors
|
|
959
|
+
*/
|
|
960
|
+
function generateErrorPage(error: Error): string {
|
|
961
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
962
|
+
|
|
963
|
+
return `<!DOCTYPE html>
|
|
964
|
+
<html lang="en">
|
|
965
|
+
<head>
|
|
966
|
+
<meta charset="utf-8">
|
|
967
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
968
|
+
<title>Error</title>
|
|
969
|
+
<style>
|
|
970
|
+
body {
|
|
971
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
972
|
+
margin: 0;
|
|
973
|
+
padding: 40px;
|
|
974
|
+
background: #f5f5f5;
|
|
975
|
+
}
|
|
976
|
+
.error-container {
|
|
977
|
+
max-width: 600px;
|
|
978
|
+
margin: 0 auto;
|
|
979
|
+
background: white;
|
|
980
|
+
padding: 40px;
|
|
981
|
+
border-radius: 8px;
|
|
982
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
983
|
+
}
|
|
984
|
+
h1 {
|
|
985
|
+
color: #d32f2f;
|
|
986
|
+
margin-top: 0;
|
|
987
|
+
}
|
|
988
|
+
pre {
|
|
989
|
+
background: #f5f5f5;
|
|
990
|
+
padding: 16px;
|
|
991
|
+
border-radius: 4px;
|
|
992
|
+
overflow-x: auto;
|
|
993
|
+
}
|
|
994
|
+
</style>
|
|
995
|
+
</head>
|
|
996
|
+
<body>
|
|
997
|
+
<div class="error-container">
|
|
998
|
+
<h1>Server Error</h1>
|
|
999
|
+
<p>An error occurred while rendering the page:</p>
|
|
1000
|
+
<pre>${error.message}</pre>
|
|
1001
|
+
${isDev && error.stack ? `<pre>${error.stack}</pre>` : ''}
|
|
1002
|
+
</div>
|
|
1003
|
+
</body>
|
|
1004
|
+
</html>`;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function generateMidStreamErrorBoundary(error: Error, componentId?: string): string {
|
|
1008
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
1009
|
+
const componentIdHtml = componentId ? `<p><strong>Component ID:</strong> ${componentId}</p>` : '';
|
|
1010
|
+
const stackHtml = error.stack
|
|
1011
|
+
? `<pre style="background:#f5f5f5;padding:10px;border-radius:4px;overflow-x:auto;font-size:12px;margin-top:10px">${error.stack}</pre>`
|
|
1012
|
+
: '';
|
|
1013
|
+
const devDetails = isDev
|
|
1014
|
+
? `<details style="margin-top:15px"><summary style="cursor:pointer;color:#856404;font-weight:bold">Error Details (Development Mode)</summary><div style="margin-top:10px">${componentIdHtml}<p><strong>Error:</strong> ${error.message}</p>${stackHtml}</div></details>`
|
|
1015
|
+
: '';
|
|
1016
|
+
const componentAttr = componentId ? ` data-component-id="${componentId}"` : '';
|
|
1017
|
+
|
|
1018
|
+
return `
|
|
1019
|
+
<div class="streaming-error-boundary" data-error-boundary="true"${componentAttr}>
|
|
1020
|
+
<div class="error-boundary-container" style="background:#fff3cd;border:2px solid #ffc107;border-radius:8px;padding:20px;margin:20px 0;font-family:system-ui,-apple-system,sans-serif">
|
|
1021
|
+
<div class="error-boundary-header" style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
|
|
1022
|
+
<span style="font-size:24px">⚠️</span>
|
|
1023
|
+
<h3 style="margin:0;color:#856404">Component Error</h3>
|
|
1024
|
+
</div>
|
|
1025
|
+
<p style="margin:10px 0;color:#856404">An error occurred while rendering this component. The rest of the page should work normally.</p>
|
|
1026
|
+
${devDetails}
|
|
1027
|
+
</div>
|
|
1028
|
+
</div>
|
|
1029
|
+
`;
|
|
1030
|
+
}
|