create-interview-cockpit 0.4.0 → 0.6.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/package.json +1 -1
- package/template/client/package-lock.json +753 -1
- package/template/client/package.json +4 -0
- package/template/client/src/App.tsx +20 -0
- package/template/client/src/api.ts +455 -3
- package/template/client/src/components/AiSettingsModal.tsx +855 -248
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +132 -27
- package/template/client/src/components/ChatView.tsx +365 -123
- package/template/client/src/components/CodeContextPanel.tsx +714 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
- package/template/client/src/components/DocRefModal.tsx +551 -0
- package/template/client/src/components/FileAttachments.tsx +128 -12
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/InfraLabModal.tsx +1706 -0
- package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
- package/template/client/src/components/MarkdownRenderer.tsx +219 -2
- package/template/client/src/components/NotesModal.tsx +977 -0
- package/template/client/src/components/PlotEmbed.tsx +173 -0
- package/template/client/src/components/Sidebar.tsx +397 -127
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +412 -25
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/infraLab.ts +124 -0
- package/template/client/src/reactLab.ts +477 -0
- package/template/client/src/store.ts +416 -2
- package/template/client/src/types.ts +41 -1
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/package.json +1 -1
- package/template/server/src/google-drive.ts +144 -2
- package/template/server/src/index.ts +1890 -188
- package/template/server/src/infra-runner.ts +1104 -0
- package/template/server/src/storage.ts +274 -3
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { InfraLabWorkspace } from "./types";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_INFRA_FILES: Record<string, string> = {
|
|
4
|
+
"provider.tf": `terraform {
|
|
5
|
+
required_providers {
|
|
6
|
+
aws = {
|
|
7
|
+
source = "hashicorp/aws"
|
|
8
|
+
version = "~> 5.0"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
provider "aws" {
|
|
14
|
+
region = "us-east-1"
|
|
15
|
+
access_key = "test"
|
|
16
|
+
secret_key = "test"
|
|
17
|
+
skip_credentials_validation = true
|
|
18
|
+
skip_metadata_api_check = true
|
|
19
|
+
skip_requesting_account_id = true
|
|
20
|
+
s3_use_path_style = true
|
|
21
|
+
|
|
22
|
+
endpoints {
|
|
23
|
+
s3 = "http://localhost:4566"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
`,
|
|
27
|
+
"main.tf": `resource "aws_s3_bucket" "example" {
|
|
28
|
+
bucket = "practice-bucket"
|
|
29
|
+
}
|
|
30
|
+
`,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_INFRA_LAB: InfraLabWorkspace = {
|
|
34
|
+
version: 1,
|
|
35
|
+
label: "AWS LocalStack Lab",
|
|
36
|
+
provider: "aws",
|
|
37
|
+
executionMode: "localstack",
|
|
38
|
+
activeFile: "main.tf",
|
|
39
|
+
files: DEFAULT_INFRA_FILES,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function cloneInfraLabWorkspace(
|
|
43
|
+
workspace?: InfraLabWorkspace | null,
|
|
44
|
+
): InfraLabWorkspace {
|
|
45
|
+
const source = workspace ?? DEFAULT_INFRA_LAB;
|
|
46
|
+
// Only seed defaults when the source has no files of its own
|
|
47
|
+
const files =
|
|
48
|
+
source.files && Object.keys(source.files).length > 0
|
|
49
|
+
? { ...source.files }
|
|
50
|
+
: { ...DEFAULT_INFRA_FILES };
|
|
51
|
+
const activeFile = files[source.activeFile]
|
|
52
|
+
? source.activeFile
|
|
53
|
+
: (Object.keys(files)[0] ?? "main.tf");
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
version: 1,
|
|
57
|
+
label: source.label || DEFAULT_INFRA_LAB.label,
|
|
58
|
+
provider: "aws",
|
|
59
|
+
executionMode:
|
|
60
|
+
source.executionMode === "plan-only" ? "plan-only" : "localstack",
|
|
61
|
+
activeFile,
|
|
62
|
+
files,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getInfraLabFileOrder(workspace: InfraLabWorkspace): string[] {
|
|
67
|
+
const preferred = [
|
|
68
|
+
"main.tf",
|
|
69
|
+
"provider.tf",
|
|
70
|
+
"variables.tf",
|
|
71
|
+
"terraform.tfvars",
|
|
72
|
+
"outputs.tf",
|
|
73
|
+
"locals.tf",
|
|
74
|
+
"README.md",
|
|
75
|
+
];
|
|
76
|
+
const extras = Object.keys(workspace.files)
|
|
77
|
+
.filter((name) => !preferred.includes(name))
|
|
78
|
+
.sort();
|
|
79
|
+
|
|
80
|
+
return preferred.filter((name) => workspace.files[name]).concat(extras);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function serializeInfraLabWorkspace(
|
|
84
|
+
workspace: InfraLabWorkspace,
|
|
85
|
+
): string {
|
|
86
|
+
return JSON.stringify(cloneInfraLabWorkspace(workspace), null, 2);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseInfraLabWorkspace(raw: string): InfraLabWorkspace | null {
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(raw) as Partial<InfraLabWorkspace> & {
|
|
92
|
+
files?: Record<string, unknown>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
96
|
+
if (!parsed.files || typeof parsed.files !== "object") return null;
|
|
97
|
+
|
|
98
|
+
const files = Object.fromEntries(
|
|
99
|
+
Object.entries(parsed.files).filter(
|
|
100
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (Object.keys(files).length === 0) return null;
|
|
105
|
+
|
|
106
|
+
return cloneInfraLabWorkspace({
|
|
107
|
+
version: 1,
|
|
108
|
+
label:
|
|
109
|
+
typeof parsed.label === "string" && parsed.label.trim()
|
|
110
|
+
? parsed.label.trim()
|
|
111
|
+
: DEFAULT_INFRA_LAB.label,
|
|
112
|
+
provider: "aws",
|
|
113
|
+
executionMode:
|
|
114
|
+
parsed.executionMode === "plan-only" ? "plan-only" : "localstack",
|
|
115
|
+
activeFile:
|
|
116
|
+
typeof parsed.activeFile === "string"
|
|
117
|
+
? parsed.activeFile
|
|
118
|
+
: DEFAULT_INFRA_LAB.activeFile,
|
|
119
|
+
files,
|
|
120
|
+
});
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import type { FrontendLabWorkspace } from "./types";
|
|
2
|
+
|
|
3
|
+
// ── Default file contents ────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const REACT_DEFAULT_FILES: Record<string, string> = {
|
|
6
|
+
"App.tsx": `import { useState } from "react";
|
|
7
|
+
import { Counter } from "./Counter";
|
|
8
|
+
import type { User } from "./types";
|
|
9
|
+
|
|
10
|
+
const user: User = { name: "Alice", age: 28 };
|
|
11
|
+
|
|
12
|
+
export default function App() {
|
|
13
|
+
const [count, setCount] = useState(0);
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
|
|
17
|
+
<h1 style={{ fontSize: "1.5rem", fontWeight: "bold", marginBottom: "0.5rem" }}>
|
|
18
|
+
React + TypeScript Lab
|
|
19
|
+
</h1>
|
|
20
|
+
<p style={{ color: "#64748b", marginBottom: "1.5rem" }}>
|
|
21
|
+
Welcome, {user.name}! Practice React fundamentals here.
|
|
22
|
+
</p>
|
|
23
|
+
<Counter initialCount={count} onCountChange={setCount} />
|
|
24
|
+
<p style={{ marginTop: "1rem", color: "#94a3b8", fontSize: "0.875rem" }}>
|
|
25
|
+
Parent count: {count}
|
|
26
|
+
</p>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
`,
|
|
31
|
+
"Counter.tsx": `import { useState, useCallback } from "react";
|
|
32
|
+
import type { CounterProps } from "./types";
|
|
33
|
+
|
|
34
|
+
// Stateful child component — receives props from App
|
|
35
|
+
export function Counter({ initialCount = 0, onCountChange }: CounterProps) {
|
|
36
|
+
const [count, setCount] = useState(initialCount);
|
|
37
|
+
|
|
38
|
+
const increment = useCallback(() => {
|
|
39
|
+
setCount((c) => {
|
|
40
|
+
const next = c + 1;
|
|
41
|
+
onCountChange?.(next);
|
|
42
|
+
return next;
|
|
43
|
+
});
|
|
44
|
+
}, [onCountChange]);
|
|
45
|
+
|
|
46
|
+
const decrement = useCallback(() => {
|
|
47
|
+
setCount((c) => {
|
|
48
|
+
const next = c - 1;
|
|
49
|
+
onCountChange?.(next);
|
|
50
|
+
return next;
|
|
51
|
+
});
|
|
52
|
+
}, [onCountChange]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
|
56
|
+
<button
|
|
57
|
+
onClick={decrement}
|
|
58
|
+
style={{
|
|
59
|
+
padding: "0.5rem 1.25rem",
|
|
60
|
+
fontSize: "1.25rem",
|
|
61
|
+
cursor: "pointer",
|
|
62
|
+
borderRadius: "0.375rem",
|
|
63
|
+
border: "1px solid #cbd5e1",
|
|
64
|
+
background: "#f8fafc",
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
−
|
|
68
|
+
</button>
|
|
69
|
+
<span style={{ fontSize: "2rem", fontWeight: "bold", minWidth: "3rem", textAlign: "center" }}>
|
|
70
|
+
{count}
|
|
71
|
+
</span>
|
|
72
|
+
<button
|
|
73
|
+
onClick={increment}
|
|
74
|
+
style={{
|
|
75
|
+
padding: "0.5rem 1.25rem",
|
|
76
|
+
fontSize: "1.25rem",
|
|
77
|
+
cursor: "pointer",
|
|
78
|
+
borderRadius: "0.375rem",
|
|
79
|
+
border: "1px solid #cbd5e1",
|
|
80
|
+
background: "#f8fafc",
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
+
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
`,
|
|
89
|
+
"types.ts": `// Type definitions — shared across components
|
|
90
|
+
|
|
91
|
+
export interface User {
|
|
92
|
+
name: string;
|
|
93
|
+
age: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CounterProps {
|
|
97
|
+
initialCount?: number;
|
|
98
|
+
/** Callback that fires whenever the count changes */
|
|
99
|
+
onCountChange?: (count: number) => void;
|
|
100
|
+
}
|
|
101
|
+
`,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const NEXTJS_DEFAULT_FILES: Record<string, string> = {
|
|
105
|
+
"app/page.tsx": `// Server Component (default in App Router — no "use client" needed)
|
|
106
|
+
// In real Next.js this could be async and fetch data directly
|
|
107
|
+
import { Counter } from "../components/Counter";
|
|
108
|
+
|
|
109
|
+
export default function HomePage() {
|
|
110
|
+
// In real Next.js: const data = await fetch('/api/...').then(r => r.json())
|
|
111
|
+
const message = "Server Components render on the server — no useState here!";
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
|
|
115
|
+
<h1 style={{ fontSize: "1.5rem", fontWeight: "bold", marginBottom: "0.5rem" }}>
|
|
116
|
+
Next.js App Router Lab
|
|
117
|
+
</h1>
|
|
118
|
+
<p style={{ color: "#64748b", marginBottom: "1.5rem" }}>{message}</p>
|
|
119
|
+
{/* Counter is a Client Component — it can use useState */}
|
|
120
|
+
<Counter />
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
`,
|
|
125
|
+
"app/layout.tsx": `// Root Layout — always a Server Component
|
|
126
|
+
// Wraps ALL pages; persists across navigations without re-mounting
|
|
127
|
+
|
|
128
|
+
export default function RootLayout({
|
|
129
|
+
children,
|
|
130
|
+
}: {
|
|
131
|
+
children: React.ReactNode;
|
|
132
|
+
}) {
|
|
133
|
+
return (
|
|
134
|
+
<html lang="en">
|
|
135
|
+
<body style={{ margin: 0, background: "#f8fafc", fontFamily: "system-ui, sans-serif" }}>
|
|
136
|
+
<nav
|
|
137
|
+
style={{
|
|
138
|
+
padding: "0.75rem 2rem",
|
|
139
|
+
background: "#0070f3",
|
|
140
|
+
color: "#fff",
|
|
141
|
+
marginBottom: "0",
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
<strong>My Next.js App</strong>
|
|
145
|
+
</nav>
|
|
146
|
+
<main>{children}</main>
|
|
147
|
+
</body>
|
|
148
|
+
</html>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
`,
|
|
152
|
+
"app/loading.tsx": `// loading.tsx — shown while the page is fetching data (Suspense boundary)
|
|
153
|
+
// Next.js displays this automatically while the page component awaits
|
|
154
|
+
|
|
155
|
+
export default function Loading() {
|
|
156
|
+
return (
|
|
157
|
+
<div style={{ padding: "2rem", color: "#64748b" }}>
|
|
158
|
+
Loading…
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
`,
|
|
163
|
+
"components/Counter.tsx": `"use client";
|
|
164
|
+
// "use client" — marks this as a Client Component
|
|
165
|
+
// Only Client Components can use useState, useEffect, and browser APIs
|
|
166
|
+
|
|
167
|
+
import { useState } from "react";
|
|
168
|
+
|
|
169
|
+
export function Counter() {
|
|
170
|
+
const [count, setCount] = useState(0);
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
|
174
|
+
<button
|
|
175
|
+
onClick={() => setCount((c) => c - 1)}
|
|
176
|
+
style={{ padding: "0.5rem 1.25rem", fontSize: "1.25rem", cursor: "pointer",
|
|
177
|
+
borderRadius: "0.375rem", border: "1px solid #cbd5e1", background: "#f8fafc" }}
|
|
178
|
+
>
|
|
179
|
+
−
|
|
180
|
+
</button>
|
|
181
|
+
<span style={{ fontSize: "2rem", fontWeight: "bold", minWidth: "3rem", textAlign: "center" }}>
|
|
182
|
+
{count}
|
|
183
|
+
</span>
|
|
184
|
+
<button
|
|
185
|
+
onClick={() => setCount((c) => c + 1)}
|
|
186
|
+
style={{ padding: "0.5rem 1.25rem", fontSize: "1.25rem", cursor: "pointer",
|
|
187
|
+
borderRadius: "0.375rem", border: "1px solid #cbd5e1", background: "#f8fafc" }}
|
|
188
|
+
>
|
|
189
|
+
+
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
`,
|
|
195
|
+
"types.ts": `// Shared TypeScript types
|
|
196
|
+
|
|
197
|
+
export interface PageProps {
|
|
198
|
+
params: { slug: string };
|
|
199
|
+
searchParams: Record<string, string | string[] | undefined>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface User {
|
|
203
|
+
id: string;
|
|
204
|
+
name: string;
|
|
205
|
+
email: string;
|
|
206
|
+
}
|
|
207
|
+
`,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// ── Lab workspace constructors ────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
export const DEFAULT_REACT_LAB: FrontendLabWorkspace = {
|
|
213
|
+
version: 1,
|
|
214
|
+
label: "React Lab",
|
|
215
|
+
type: "react",
|
|
216
|
+
activeFile: "App.tsx",
|
|
217
|
+
files: REACT_DEFAULT_FILES,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const DEFAULT_NEXTJS_LAB: FrontendLabWorkspace = {
|
|
221
|
+
version: 1,
|
|
222
|
+
label: "Next.js Lab",
|
|
223
|
+
type: "nextjs",
|
|
224
|
+
activeFile: "app/page.tsx",
|
|
225
|
+
files: NEXTJS_DEFAULT_FILES,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export function defaultForType(type: "react" | "nextjs"): FrontendLabWorkspace {
|
|
229
|
+
return type === "nextjs" ? DEFAULT_NEXTJS_LAB : DEFAULT_REACT_LAB;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function cloneFrontendLabWorkspace(
|
|
233
|
+
workspace?: FrontendLabWorkspace | null,
|
|
234
|
+
type?: "react" | "nextjs",
|
|
235
|
+
): FrontendLabWorkspace {
|
|
236
|
+
const resolvedType = workspace?.type ?? type ?? "react";
|
|
237
|
+
const defaults = defaultForType(resolvedType);
|
|
238
|
+
const source = workspace ?? defaults;
|
|
239
|
+
const files =
|
|
240
|
+
source.files && Object.keys(source.files).length > 0
|
|
241
|
+
? { ...source.files }
|
|
242
|
+
: { ...defaults.files };
|
|
243
|
+
const activeFile = files[source.activeFile]
|
|
244
|
+
? source.activeFile
|
|
245
|
+
: (Object.keys(files)[0] ?? defaults.activeFile);
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
version: 1,
|
|
249
|
+
label: source.label?.trim() || defaults.label,
|
|
250
|
+
type: resolvedType,
|
|
251
|
+
activeFile,
|
|
252
|
+
files,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function serializeFrontendLabWorkspace(
|
|
257
|
+
workspace: FrontendLabWorkspace,
|
|
258
|
+
): string {
|
|
259
|
+
return JSON.stringify(cloneFrontendLabWorkspace(workspace), null, 2);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function parseFrontendLabWorkspace(
|
|
263
|
+
raw: string,
|
|
264
|
+
): FrontendLabWorkspace | null {
|
|
265
|
+
try {
|
|
266
|
+
const parsed = JSON.parse(raw) as Partial<FrontendLabWorkspace> & {
|
|
267
|
+
files?: Record<string, unknown>;
|
|
268
|
+
};
|
|
269
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
270
|
+
if (!parsed.files || typeof parsed.files !== "object") return null;
|
|
271
|
+
|
|
272
|
+
const files = Object.fromEntries(
|
|
273
|
+
Object.entries(parsed.files).filter(
|
|
274
|
+
(e): e is [string, string] => typeof e[1] === "string",
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
if (Object.keys(files).length === 0) return null;
|
|
278
|
+
|
|
279
|
+
const type: "react" | "nextjs" =
|
|
280
|
+
parsed.type === "nextjs" ? "nextjs" : "react";
|
|
281
|
+
|
|
282
|
+
return cloneFrontendLabWorkspace({
|
|
283
|
+
version: 1,
|
|
284
|
+
type,
|
|
285
|
+
label:
|
|
286
|
+
typeof parsed.label === "string" && parsed.label.trim()
|
|
287
|
+
? parsed.label.trim()
|
|
288
|
+
: defaultForType(type).label,
|
|
289
|
+
activeFile:
|
|
290
|
+
typeof parsed.activeFile === "string"
|
|
291
|
+
? parsed.activeFile
|
|
292
|
+
: defaultForType(type).activeFile,
|
|
293
|
+
files,
|
|
294
|
+
});
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Returns the canonical entry file for "Run" → preview. */
|
|
301
|
+
export function getEntryFile(workspace: FrontendLabWorkspace): string {
|
|
302
|
+
if (workspace.type === "nextjs") {
|
|
303
|
+
return workspace.files["app/page.tsx"]
|
|
304
|
+
? "app/page.tsx"
|
|
305
|
+
: Object.keys(workspace.files)[0];
|
|
306
|
+
}
|
|
307
|
+
return workspace.files["App.tsx"]
|
|
308
|
+
? "App.tsx"
|
|
309
|
+
: Object.keys(workspace.files)[0];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Preferred display order for the file tree. */
|
|
313
|
+
export function getFrontendLabFileOrder(
|
|
314
|
+
workspace: FrontendLabWorkspace,
|
|
315
|
+
): string[] {
|
|
316
|
+
const allFiles = Object.keys(workspace.files).sort((a, b) => {
|
|
317
|
+
// Sort by folder depth first, then alphabetically
|
|
318
|
+
const ad = a.split("/").length;
|
|
319
|
+
const bd = b.split("/").length;
|
|
320
|
+
return ad !== bd ? ad - bd : a.localeCompare(b);
|
|
321
|
+
});
|
|
322
|
+
return allFiles;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Preview HTML generator ────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Resolves which page.tsx file corresponds to a Next.js route path.
|
|
329
|
+
* Returns null if no matching file exists in `files`.
|
|
330
|
+
*/
|
|
331
|
+
export function resolveNextjsEntry(
|
|
332
|
+
files: Record<string, string>,
|
|
333
|
+
routePath: string,
|
|
334
|
+
): string | null {
|
|
335
|
+
const segments = routePath.replace(/^\//, "").split("/").filter(Boolean);
|
|
336
|
+
const base =
|
|
337
|
+
segments.length === 0 ? "app/page" : `app/${segments.join("/")}/page`;
|
|
338
|
+
for (const ext of [".tsx", ".ts", ".jsx", ".js"]) {
|
|
339
|
+
if (files[base + ext] !== undefined) return base + ext;
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Generates a self-contained HTML page for the preview iframe.
|
|
346
|
+
*
|
|
347
|
+
* Approach: loads React 18 UMD + Babel standalone from CDN, runs a
|
|
348
|
+
* custom module system built on top of Babel's CJS transform plugin,
|
|
349
|
+
* then renders the default export from `entryFile`.
|
|
350
|
+
*
|
|
351
|
+
* CDN URLs are version-pinned so the preview is reproducible.
|
|
352
|
+
*/
|
|
353
|
+
export function generatePreviewHTML(
|
|
354
|
+
files: Record<string, string>,
|
|
355
|
+
entryFile: string,
|
|
356
|
+
sandboxUrl?: string,
|
|
357
|
+
isNextjs?: boolean,
|
|
358
|
+
): string {
|
|
359
|
+
const filesJSON = JSON.stringify(files);
|
|
360
|
+
const entryJSON = JSON.stringify(entryFile);
|
|
361
|
+
const sandboxJSON = JSON.stringify(sandboxUrl ?? "");
|
|
362
|
+
const isNextjsJSON = isNextjs ? "true" : "false";
|
|
363
|
+
// _i breaks up the 'import' keyword so Vite/Babel doesn't misparse
|
|
364
|
+
// the template literal below as containing real module import declarations
|
|
365
|
+
const _i = "import";
|
|
366
|
+
|
|
367
|
+
return `<!DOCTYPE html>
|
|
368
|
+
<html>
|
|
369
|
+
<head>
|
|
370
|
+
<meta charset="utf-8">
|
|
371
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
372
|
+
<script>window.__F__=${filesJSON};window.__E__=${entryJSON};window.SANDBOX_URL=${sandboxJSON};window.__NX__=${isNextjsJSON};</script>
|
|
373
|
+
<script src="https://unpkg.com/@babel/standalone@7.26.10/babel.min.js"></script>
|
|
374
|
+
<style>
|
|
375
|
+
*{box-sizing:border-box}
|
|
376
|
+
body{margin:0;background:#fff;font-family:system-ui,sans-serif}
|
|
377
|
+
#__err{display:none;position:fixed;bottom:0;left:0;right:0;padding:0.75rem 1rem;background:#fef2f2;color:#991b1b;font:12px/1.5 monospace;white-space:pre-wrap;border-top:2px solid #fca5a5;max-height:50%;overflow:auto;z-index:9999}
|
|
378
|
+
</style>
|
|
379
|
+
</head>
|
|
380
|
+
<body>
|
|
381
|
+
<div id="root"></div>
|
|
382
|
+
<div id="__err"></div>
|
|
383
|
+
<script type="module">
|
|
384
|
+
${_i} React from 'https://esm.sh/react@19.1.0';
|
|
385
|
+
${_i} * as ReactDOM from 'https://esm.sh/react-dom@19.1.0/client?deps=react@19.1.0';
|
|
386
|
+
window.React = React;
|
|
387
|
+
window.ReactDOM = ReactDOM;
|
|
388
|
+
(function(){
|
|
389
|
+
var files=window.__F__,entry=window.__E__,reg={};
|
|
390
|
+
function norm(from,id){
|
|
391
|
+
if(id==='react'||id==='react/jsx-runtime'||id==='react/jsx-dev-runtime')return'__react__';
|
|
392
|
+
if(id==='react-dom'||id==='react-dom/server')return'__reactdom__';
|
|
393
|
+
if(id==='react-dom/client')return'__reactdomclient__';
|
|
394
|
+
if(!id.startsWith('.')){return'__ext__:'+id;}
|
|
395
|
+
var dir=from.includes('/')?from.slice(0,from.lastIndexOf('/')+1):'';
|
|
396
|
+
var parts=(dir+id).split('/').reduce(function(a,p){
|
|
397
|
+
if(p==='..')a.pop();else if(p&&p!=='.')a.push(p);return a;
|
|
398
|
+
},[]);
|
|
399
|
+
var base=parts.join('/');
|
|
400
|
+
var exts=['','.tsx','.ts','.jsx','.js'];
|
|
401
|
+
for(var i=0;i<exts.length;i++){if(files[base+exts[i]]!=null)return base+exts[i];}
|
|
402
|
+
return base;
|
|
403
|
+
}
|
|
404
|
+
function makeReq(from){
|
|
405
|
+
return function(id){
|
|
406
|
+
if(id==='react'||id==='react/jsx-runtime'||id==='react/jsx-dev-runtime')return window.React;
|
|
407
|
+
if(id==='react-dom/client')return{createRoot:window.ReactDOM.createRoot.bind(window.ReactDOM)};
|
|
408
|
+
if(id==='react-dom')return window.ReactDOM;
|
|
409
|
+
var key=norm(from,id);
|
|
410
|
+
if(key.startsWith('__ext__:'))return{};
|
|
411
|
+
if(reg[key])return reg[key].exports;
|
|
412
|
+
for(var e of['.tsx','.ts','.jsx','.js']){if(reg[key+e])return reg[key+e].exports;}
|
|
413
|
+
console.warn('Module not found:',id,'from',from);return{};
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
function loadMod(name){
|
|
417
|
+
if(reg[name])return;
|
|
418
|
+
var src=files[name];if(src==null)return;
|
|
419
|
+
var m={exports:{}};
|
|
420
|
+
reg[name]=m;
|
|
421
|
+
try{
|
|
422
|
+
var out=Babel.transform(src,{
|
|
423
|
+
presets:[['react',{runtime:'classic'}],['typescript',{allExtensions:true,isTSX:true}]],
|
|
424
|
+
plugins:['transform-modules-commonjs','transform-dynamic-import'],
|
|
425
|
+
filename:name,sourceType:'module'
|
|
426
|
+
}).code;
|
|
427
|
+
(new Function('require','module','exports',out))(makeReq(name),m,m.exports);
|
|
428
|
+
}catch(e){throw new Error(name+': '+e.message);}
|
|
429
|
+
}
|
|
430
|
+
function deps(name){
|
|
431
|
+
var src=files[name]||'',re=/from\\s+['""]([^'"]+)['"]/g,d=[],m;
|
|
432
|
+
while((m=re.exec(src))!==null){
|
|
433
|
+
var k=norm(name,m[1]);
|
|
434
|
+
if(k&&!k.startsWith('__')&&files[k])d.push(k);
|
|
435
|
+
}
|
|
436
|
+
return d;
|
|
437
|
+
}
|
|
438
|
+
var vis=new Set(),order=[];
|
|
439
|
+
function visit(n){if(vis.has(n))return;vis.add(n);deps(n).forEach(visit);order.push(n);}
|
|
440
|
+
Object.keys(files).forEach(visit);
|
|
441
|
+
function showErr(msg){
|
|
442
|
+
var el=document.getElementById('__err');
|
|
443
|
+
el.style.display='block';el.innerText=msg;
|
|
444
|
+
try{parent.postMessage({type:'rlab-err',error:msg},'*');}catch(e){}
|
|
445
|
+
}
|
|
446
|
+
window.onerror=function(msg,s,l,c,err){showErr(err?err.message+'\\n'+(err.stack||''):String(msg));return true;};
|
|
447
|
+
window.addEventListener('unhandledrejection',function(e){showErr(e.reason&&e.reason.message?e.reason.message:String(e.reason));});
|
|
448
|
+
try{
|
|
449
|
+
order.forEach(loadMod);
|
|
450
|
+
var em=reg[entry];
|
|
451
|
+
if(!em)throw new Error('Entry not found: '+entry);
|
|
452
|
+
var Comp=em.exports.default;
|
|
453
|
+
if(typeof Comp!=='function')throw new Error('No default export (function/component) in '+entry);
|
|
454
|
+
// Expose a navigate helper so in-preview code can trigger URL bar changes:
|
|
455
|
+
// window.__nxNavigate('/dashboard')
|
|
456
|
+
window.__nxNavigate=function(to){try{parent.postMessage({type:'rlab-nav',to:to},'*');}catch(e){}};
|
|
457
|
+
var pageEl=React.createElement(Comp,null);
|
|
458
|
+
// In Next.js mode: wrap the page in app/layout.tsx if it exists
|
|
459
|
+
if(window.__NX__){
|
|
460
|
+
var lk=null;
|
|
461
|
+
for(var _le of['app/layout.tsx','app/layout.ts','app/layout.jsx','app/layout.js']){
|
|
462
|
+
if(reg[_le]){lk=_le;break;}
|
|
463
|
+
}
|
|
464
|
+
if(lk&&typeof reg[lk].exports.default==='function'){
|
|
465
|
+
pageEl=React.createElement(reg[lk].exports.default,null,pageEl);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
469
|
+
React.createElement(React.StrictMode,null,pageEl)
|
|
470
|
+
);
|
|
471
|
+
try{parent.postMessage({type:'rlab-ready'},'*');}catch(e){}
|
|
472
|
+
}catch(err){showErr(err.message+(err.stack?'\\n\\n'+err.stack:''));}
|
|
473
|
+
})();
|
|
474
|
+
</script>
|
|
475
|
+
</body>
|
|
476
|
+
</html>`;
|
|
477
|
+
}
|