create-utopia 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matt Hesketh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # create-utopia
2
+
3
+ Scaffold a new UtopiaJS project.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx create-utopia my-app
9
+ ```
10
+
11
+ Or with other package managers:
12
+
13
+ ```bash
14
+ pnpm create utopia my-app
15
+ yarn create utopia my-app
16
+ bun create utopia my-app
17
+ ```
18
+
19
+ ## Features
20
+
21
+ The interactive CLI prompts for:
22
+
23
+ - **Project name** -- validates as a legal npm package name
24
+ - **Language** -- TypeScript (default) or JavaScript
25
+ - **Features** -- select any combination:
26
+ - Router (file-based routing)
27
+ - SSR (server-side rendering with Express)
28
+ - Email (template-based emails)
29
+ - AI (chat, streaming, adapters)
30
+ - CSS Preprocessor (Sass or Less)
31
+ - **Git initialization** -- optional initial commit
32
+
33
+ ## What It Creates
34
+
35
+ ```
36
+ my-app/
37
+ index.html
38
+ package.json
39
+ vite.config.ts
40
+ tsconfig.json # TypeScript only
41
+ src/
42
+ main.ts
43
+ App.utopia
44
+ routes/ # if Router selected
45
+ +page.utopia
46
+ +layout.utopia
47
+ server.js # if SSR selected
48
+ src/
49
+ entry-client.ts # if SSR selected
50
+ entry-server.ts # if SSR selected
51
+ ```
52
+
53
+ When AI is selected, an example chat API route is scaffolded at `src/routes/api/chat/+server.ts` with a `.env.example` file.
54
+
55
+ ## Programmatic
56
+
57
+ The CLI is the primary interface. The package exports no public API -- it runs directly via the `create-utopia` bin entry point.
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/index.js ADDED
@@ -0,0 +1,436 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import fs from "fs";
5
+ import path from "path";
6
+ import { execSync } from "child_process";
7
+ import { fileURLToPath } from "url";
8
+ import prompts from "prompts";
9
+ import { green, cyan, yellow, bold, red, dim } from "kolorist";
10
+ function isValidPackageName(name) {
11
+ return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name);
12
+ }
13
+ function toValidPackageName(name) {
14
+ return name.trim().toLowerCase().replace(/\s+/g, "-").replace(/^[._]/, "").replace(/[^a-z0-9-~]+/g, "-");
15
+ }
16
+ function isEmptyDir(dirPath) {
17
+ if (!fs.existsSync(dirPath)) return true;
18
+ const files = fs.readdirSync(dirPath);
19
+ return files.length === 0 || files.length === 1 && files[0] === ".git";
20
+ }
21
+ function copyDir(src, dest, skip = /* @__PURE__ */ new Set(), _root) {
22
+ const root = _root ?? src;
23
+ fs.mkdirSync(dest, { recursive: true });
24
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
25
+ const srcPath = path.join(src, entry.name);
26
+ const destPath = path.join(dest, entry.name);
27
+ const relativePath = path.relative(root, srcPath);
28
+ const shouldSkip = skip.has(relativePath) || [...skip].some(
29
+ (s) => relativePath.startsWith(s + path.sep)
30
+ );
31
+ if (shouldSkip) continue;
32
+ if (entry.isDirectory()) {
33
+ copyDir(srcPath, destPath, skip, root);
34
+ } else {
35
+ fs.copyFileSync(srcPath, destPath);
36
+ }
37
+ }
38
+ }
39
+ function removeDir(dirPath) {
40
+ if (!fs.existsSync(dirPath)) return;
41
+ fs.rmSync(dirPath, { recursive: true, force: true });
42
+ }
43
+ function replaceInFile(filePath, replacements) {
44
+ let content = fs.readFileSync(filePath, "utf-8");
45
+ for (const [search, replace] of Object.entries(replacements)) {
46
+ content = content.replaceAll(search, replace);
47
+ }
48
+ fs.writeFileSync(filePath, content, "utf-8");
49
+ }
50
+ function renameFile(dir, from, to) {
51
+ const srcPath = path.join(dir, from);
52
+ if (fs.existsSync(srcPath)) {
53
+ fs.renameSync(srcPath, path.join(dir, to));
54
+ }
55
+ }
56
+ function scaffoldProject(root, options) {
57
+ const { projectName, language, useRouter, useSSR, useEmail, useAI, cssPreprocessor } = options;
58
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
59
+ const templateDir = path.resolve(__dirname, "..", "template");
60
+ const skip = /* @__PURE__ */ new Set();
61
+ if (!useRouter) {
62
+ skip.add(path.join("src", "routes"));
63
+ }
64
+ if (language === "javascript") {
65
+ skip.add("tsconfig.json");
66
+ }
67
+ if (!useSSR) {
68
+ skip.add(path.join("src", "entry-server.ts"));
69
+ skip.add(path.join("src", "entry-client.ts"));
70
+ skip.add("server.js");
71
+ }
72
+ copyDir(templateDir, root, skip);
73
+ const pkgJsonPath = path.join(root, "package.json");
74
+ const indexHtmlPath = path.join(root, "index.html");
75
+ replaceInFile(pkgJsonPath, { "{{projectName}}": projectName });
76
+ replaceInFile(indexHtmlPath, { "{{projectName}}": projectName });
77
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
78
+ const deps = pkg["dependencies"];
79
+ const devDeps = pkg["devDependencies"];
80
+ if (!useRouter && deps) {
81
+ delete deps["@matthesketh/utopia-router"];
82
+ }
83
+ if (language === "javascript" && devDeps) {
84
+ delete devDeps["typescript"];
85
+ }
86
+ if (cssPreprocessor === "sass" && devDeps) {
87
+ devDeps["sass"] = "^1.80.0";
88
+ } else if (cssPreprocessor === "less" && devDeps) {
89
+ devDeps["less"] = "^4.2.0";
90
+ }
91
+ if (useEmail && deps) {
92
+ deps["@matthesketh/utopia-email"] = "^0.0.1";
93
+ }
94
+ if (useAI && deps) {
95
+ deps["@matthesketh/utopia-ai"] = "^0.0.1";
96
+ }
97
+ fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
98
+ if (language === "javascript") {
99
+ renameFile(root, "vite.config.ts", "vite.config.js");
100
+ renameFile(path.join(root, "src"), "main.ts", "main.js");
101
+ replaceInFile(indexHtmlPath, { "/src/main.ts": "/src/main.js" });
102
+ }
103
+ if (useSSR) {
104
+ const ssrPkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
105
+ const ssrDeps = ssrPkg["dependencies"];
106
+ const ssrDevDeps = ssrPkg["devDependencies"];
107
+ ssrDeps["@matthesketh/utopia-server"] = "^0.0.1";
108
+ ssrDeps["express"] = "^4.21.0";
109
+ if (ssrDevDeps["vite"]) {
110
+ ssrDeps["vite"] = ssrDevDeps["vite"];
111
+ }
112
+ const scripts = ssrPkg["scripts"];
113
+ scripts["dev"] = "node server.js";
114
+ scripts["build"] = "npm run build:client && npm run build:server";
115
+ scripts["build:client"] = "vite build --outDir dist/client";
116
+ scripts["build:server"] = "vite build --outDir dist/server --ssr src/entry-server.ts";
117
+ scripts["preview"] = "NODE_ENV=production node server.js";
118
+ fs.writeFileSync(pkgJsonPath, JSON.stringify(ssrPkg, null, 2) + "\n", "utf-8");
119
+ const ssrIndexHtml = `<!DOCTYPE html>
120
+ <html lang="en">
121
+ <head>
122
+ <meta charset="UTF-8">
123
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
124
+ <title>${projectName}</title>
125
+ <!--ssr-head-->
126
+ </head>
127
+ <body>
128
+ <div id="app"><!--ssr-outlet--></div>
129
+ <script type="module" src="/src/entry-client.ts"></script>
130
+ </body>
131
+ </html>
132
+ `;
133
+ fs.writeFileSync(indexHtmlPath, ssrIndexHtml, "utf-8");
134
+ const mainTsPath = path.join(root, "src", "main.ts");
135
+ if (fs.existsSync(mainTsPath)) {
136
+ fs.rmSync(mainTsPath);
137
+ }
138
+ if (language === "javascript") {
139
+ renameFile(path.join(root, "src"), "entry-client.ts", "entry-client.js");
140
+ renameFile(path.join(root, "src"), "entry-server.ts", "entry-server.js");
141
+ replaceInFile(indexHtmlPath, { "/src/entry-client.ts": "/src/entry-client.js" });
142
+ replaceInFile(
143
+ path.join(root, "server.js"),
144
+ { "entry-server.ts": "entry-server.js" }
145
+ );
146
+ }
147
+ }
148
+ if (!useRouter) {
149
+ const appPath = path.join(root, "src", "App.utopia");
150
+ const simpleApp = `<template>
151
+ <div id="app">
152
+ <h1>Welcome to UtopiaJS</h1>
153
+ <p>Edit <code>src/App.utopia</code> to get started.</p>
154
+ </div>
155
+ </template>
156
+
157
+ <style>
158
+ #app {
159
+ font-family: system-ui, -apple-system, sans-serif;
160
+ max-width: 800px;
161
+ margin: 0 auto;
162
+ padding: 20px;
163
+ }
164
+
165
+ h1 {
166
+ color: #333;
167
+ }
168
+ </style>
169
+ `;
170
+ fs.writeFileSync(appPath, simpleApp, "utf-8");
171
+ const mainPath = path.join(root, "src", language === "typescript" ? "main.ts" : "main.js");
172
+ const simpleMain = `import { mount } from '@matthesketh/utopia-runtime'
173
+ import App from './App.utopia'
174
+
175
+ mount(App, '#app')
176
+ `;
177
+ fs.writeFileSync(mainPath, simpleMain, "utf-8");
178
+ }
179
+ if (useAI) {
180
+ const ext = language === "typescript" ? "ts" : "js";
181
+ const apiChatDir = path.join(root, "src", "routes", "api", "chat");
182
+ fs.mkdirSync(apiChatDir, { recursive: true });
183
+ const serverFile = path.join(apiChatDir, `+server.${ext}`);
184
+ const serverContent = `import { createAI } from '@matthesketh/utopia-ai';
185
+ import { openaiAdapter } from '@matthesketh/utopia-ai/openai';
186
+ import { streamSSE } from '@matthesketh/utopia-ai';
187
+
188
+ const ai = createAI(openaiAdapter({
189
+ apiKey: process.env.OPENAI_API_KEY!,
190
+ }));
191
+
192
+ export async function POST(req${language === "typescript" ? ": any" : ""}, res${language === "typescript" ? ": any" : ""}) {
193
+ // Parse request body
194
+ const body = await new Promise${language === "typescript" ? "<string>" : ""}((resolve) => {
195
+ let data = '';
196
+ req.on('data', (chunk${language === "typescript" ? ": Buffer" : ""}) => { data += chunk.toString(); });
197
+ req.on('end', () => resolve(data));
198
+ });
199
+
200
+ const { messages } = JSON.parse(body);
201
+ const stream = ai.stream({ messages, model: 'gpt-4o' });
202
+ await streamSSE(res, stream);
203
+ }
204
+ `;
205
+ fs.writeFileSync(serverFile, serverContent, "utf-8");
206
+ const envExamplePath = path.join(root, ".env.example");
207
+ fs.writeFileSync(envExamplePath, "OPENAI_API_KEY=sk-your-key-here\n", "utf-8");
208
+ }
209
+ }
210
+ function initGitRepo(root) {
211
+ try {
212
+ execSync("git init", { cwd: root, stdio: "ignore" });
213
+ execSync("git add -A", { cwd: root, stdio: "ignore" });
214
+ execSync('git commit -m "Initial commit (created with create-utopia)"', {
215
+ cwd: root,
216
+ stdio: "ignore"
217
+ });
218
+ return true;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+ function detectPackageManager() {
224
+ const userAgent = process.env["npm_config_user_agent"] ?? "";
225
+ if (userAgent.startsWith("yarn")) return "yarn";
226
+ if (userAgent.startsWith("pnpm")) return "pnpm";
227
+ if (userAgent.startsWith("bun")) return "bun";
228
+ return "npm";
229
+ }
230
+ function getPackageManagerCommands(pm) {
231
+ switch (pm) {
232
+ case "yarn":
233
+ return { install: "yarn", dev: "yarn dev" };
234
+ case "pnpm":
235
+ return { install: "pnpm install", dev: "pnpm dev" };
236
+ case "bun":
237
+ return { install: "bun install", dev: "bun dev" };
238
+ default:
239
+ return { install: "npm install", dev: "npm run dev" };
240
+ }
241
+ }
242
+ function printBanner() {
243
+ console.log();
244
+ console.log(bold(cyan(" create-utopia")) + dim(" v0.0.1"));
245
+ console.log();
246
+ }
247
+ function printSuccessBox(projectName, root) {
248
+ const pm = detectPackageManager();
249
+ const cmds = getPackageManagerCommands(pm);
250
+ const cwd = process.cwd();
251
+ const cdPath = path.relative(cwd, root);
252
+ const lines = [
253
+ "",
254
+ green(" UtopiaJS Project Ready!"),
255
+ ""
256
+ ];
257
+ const boxWidth = 35;
258
+ const top = ` \u256D${"\u2500".repeat(boxWidth)}\u256E`;
259
+ const bottom = ` \u2570${"\u2500".repeat(boxWidth)}\u256F`;
260
+ console.log();
261
+ console.log(top);
262
+ for (const line of lines) {
263
+ const padding = " ".repeat(Math.max(0, boxWidth - stripAnsi(line).length));
264
+ console.log(` \u2502${line}${padding}\u2502`);
265
+ }
266
+ console.log(bottom);
267
+ console.log();
268
+ console.log(" Next steps:");
269
+ if (cdPath !== ".") {
270
+ console.log(cyan(` cd ${cdPath}`));
271
+ }
272
+ console.log(cyan(` ${cmds.install}`));
273
+ console.log(cyan(` ${cmds.dev}`));
274
+ console.log();
275
+ console.log(` Happy coding! \u{1F680}`);
276
+ console.log();
277
+ }
278
+ function stripAnsi(str) {
279
+ return str.replace(/\u001B\[[0-9;]*m/g, "");
280
+ }
281
+ async function main() {
282
+ printBanner();
283
+ const argProjectName = process.argv[2]?.trim();
284
+ const defaultProjectName = argProjectName ?? "utopia-app";
285
+ let response;
286
+ try {
287
+ response = await prompts(
288
+ [
289
+ {
290
+ type: argProjectName ? null : "text",
291
+ name: "projectName",
292
+ message: "Project name:",
293
+ initial: defaultProjectName,
294
+ validate: (value) => {
295
+ const name = toValidPackageName(value);
296
+ if (!isValidPackageName(name)) {
297
+ return "Invalid package name. Use lowercase letters, numbers, and hyphens.";
298
+ }
299
+ return true;
300
+ }
301
+ },
302
+ {
303
+ type: (_prev, values) => {
304
+ const name = values["projectName"] ?? defaultProjectName;
305
+ const targetDir = path.resolve(process.cwd(), name);
306
+ if (!isEmptyDir(targetDir)) return "confirm";
307
+ return null;
308
+ },
309
+ name: "overwrite",
310
+ message: (_prev, values) => {
311
+ const name = values["projectName"] ?? defaultProjectName;
312
+ return `Target directory "${name}" is not empty. Remove existing files and continue?`;
313
+ }
314
+ },
315
+ {
316
+ type: "select",
317
+ name: "language",
318
+ message: "Language:",
319
+ choices: [
320
+ { title: "TypeScript", value: "typescript" },
321
+ { title: "JavaScript", value: "javascript" }
322
+ ],
323
+ initial: 0
324
+ },
325
+ {
326
+ type: "multiselect",
327
+ name: "features",
328
+ message: "Features:",
329
+ choices: [
330
+ { title: "Router (file-based routing)", value: "router", selected: true },
331
+ { title: "SSR (server-side rendering)", value: "ssr", selected: false },
332
+ { title: "Email (template-based emails)", value: "email", selected: false },
333
+ { title: "AI (chat, streaming, adapters)", value: "ai", selected: false },
334
+ { title: "CSS Preprocessor", value: "css-preprocessor", selected: false }
335
+ ],
336
+ instructions: dim(" (use space to toggle, enter to confirm)")
337
+ },
338
+ {
339
+ type: (_prev, values) => {
340
+ const features2 = values["features"];
341
+ return features2?.includes("css-preprocessor") ? "select" : null;
342
+ },
343
+ name: "cssPreprocessor",
344
+ message: "CSS Preprocessor:",
345
+ choices: [
346
+ { title: "Sass", value: "sass" },
347
+ { title: "Less", value: "less" }
348
+ ],
349
+ initial: 0
350
+ },
351
+ {
352
+ type: "confirm",
353
+ name: "initGit",
354
+ message: "Initialize a git repository?",
355
+ initial: true
356
+ }
357
+ ],
358
+ {
359
+ onCancel: () => {
360
+ console.log();
361
+ console.log(red(" Operation cancelled."));
362
+ console.log();
363
+ process.exit(1);
364
+ }
365
+ }
366
+ );
367
+ } catch (err) {
368
+ console.error(err);
369
+ process.exit(1);
370
+ }
371
+ const projectName = toValidPackageName(response.projectName ?? defaultProjectName);
372
+ const overwrite = response.overwrite ?? true;
373
+ const language = response.language ?? "typescript";
374
+ const features = response.features ?? ["router"];
375
+ const cssPreprocessor = response.cssPreprocessor ?? "none";
376
+ const shouldInitGit = response.initGit ?? true;
377
+ const useRouter = features.includes("router");
378
+ const useSSR = features.includes("ssr");
379
+ const useEmail = features.includes("email");
380
+ const useAI = features.includes("ai");
381
+ const root = path.resolve(process.cwd(), projectName);
382
+ if (fs.existsSync(root) && !isEmptyDir(root)) {
383
+ if (!overwrite) {
384
+ console.log(red(" Aborting."));
385
+ process.exit(1);
386
+ }
387
+ console.log(yellow(` Removing existing files in ${projectName}...`));
388
+ removeDir(root);
389
+ }
390
+ console.log();
391
+ console.log(` Scaffolding project in ${cyan(root)}...`);
392
+ console.log();
393
+ const options = {
394
+ projectName,
395
+ language,
396
+ useRouter,
397
+ useSSR,
398
+ useEmail,
399
+ useAI,
400
+ cssPreprocessor,
401
+ initGit: shouldInitGit
402
+ };
403
+ scaffoldProject(root, options);
404
+ const createdFiles = listFiles(root, root);
405
+ for (const file of createdFiles) {
406
+ console.log(` ${dim("+")} ${file}`);
407
+ }
408
+ console.log();
409
+ if (shouldInitGit) {
410
+ const gitSuccess = initGitRepo(root);
411
+ if (gitSuccess) {
412
+ console.log(green(" Initialized git repository."));
413
+ } else {
414
+ console.log(yellow(" Could not initialize git repository."));
415
+ }
416
+ }
417
+ printSuccessBox(projectName, root);
418
+ }
419
+ function listFiles(dir, base) {
420
+ const results = [];
421
+ if (!fs.existsSync(dir)) return results;
422
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
423
+ const fullPath = path.join(dir, entry.name);
424
+ const relativePath = path.relative(base, fullPath);
425
+ if (entry.isDirectory()) {
426
+ results.push(...listFiles(fullPath, base));
427
+ } else {
428
+ results.push(relativePath);
429
+ }
430
+ }
431
+ return results;
432
+ }
433
+ main().catch((err) => {
434
+ console.error(err);
435
+ process.exit(1);
436
+ });
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "create-utopia",
3
+ "version": "0.0.1",
4
+ "description": "Scaffold a new UtopiaJS project",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Matt <matt@matthesketh.pro>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/wrxck/utopiajs.git",
11
+ "directory": "packages/create-utopia"
12
+ },
13
+ "homepage": "https://matthesketh.pro",
14
+ "keywords": [
15
+ "create",
16
+ "scaffold",
17
+ "cli",
18
+ "generator",
19
+ "starter",
20
+ "utopiajs"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20.0.0"
24
+ },
25
+ "bin": {
26
+ "create-utopia": "./dist/index.js"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "template"
31
+ ],
32
+ "dependencies": {
33
+ "prompts": "^2.4.2",
34
+ "kolorist": "^1.8.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/prompts": "^2.4.9",
38
+ "tsup": "^8.0.0",
39
+ "typescript": "^5.7.0"
40
+ },
41
+ "scripts": {
42
+ "build": "tsup src/index.ts --format esm --dts",
43
+ "dev": "tsup src/index.ts --format esm --dts --watch"
44
+ }
45
+ }
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{projectName}}</title>
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.ts"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@matthesketh/utopia-core": "^0.0.1",
13
+ "@matthesketh/utopia-runtime": "^0.0.1",
14
+ "@matthesketh/utopia-router": "^0.0.1"
15
+ },
16
+ "devDependencies": {
17
+ "@matthesketh/utopia-vite-plugin": "^0.0.1",
18
+ "typescript": "^5.7.0",
19
+ "vite": "^6.0.0"
20
+ }
21
+ }
@@ -0,0 +1,75 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import express from 'express'
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
+ const isProduction = process.env.NODE_ENV === 'production'
8
+
9
+ async function createServer() {
10
+ const app = express()
11
+
12
+ let vite
13
+
14
+ if (!isProduction) {
15
+ // In development, use Vite's dev server as middleware
16
+ const { createServer: createViteServer } = await import('vite')
17
+ vite = await createViteServer({
18
+ server: { middlewareMode: true },
19
+ appType: 'custom',
20
+ })
21
+ app.use(vite.middlewares)
22
+ } else {
23
+ // In production, serve the built client assets
24
+ app.use(express.static(path.resolve(__dirname, 'dist/client')))
25
+ }
26
+
27
+ app.use('*', async (req, res) => {
28
+ const url = req.originalUrl
29
+
30
+ try {
31
+ let template
32
+ let render
33
+
34
+ if (!isProduction) {
35
+ // In dev, read index.html and transform it through Vite
36
+ template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8')
37
+ template = await vite.transformIndexHtml(url, template)
38
+
39
+ // Load the server entry through Vite so it gets HMR
40
+ const mod = await vite.ssrLoadModule('/src/entry-server.ts')
41
+ render = mod.render
42
+ } else {
43
+ // In production, use the pre-built files
44
+ template = fs.readFileSync(
45
+ path.resolve(__dirname, 'dist/client/index.html'),
46
+ 'utf-8',
47
+ )
48
+ const mod = await import('./dist/server/entry-server.js')
49
+ render = mod.render
50
+ }
51
+
52
+ const { html: appHtml, css } = render(url)
53
+
54
+ // Inject the rendered HTML and CSS into the template
55
+ let page = template
56
+ page = page.replace('<!--ssr-head-->', css ? `<style>${css}</style>` : '')
57
+ page = page.replace('<!--ssr-outlet-->', appHtml)
58
+
59
+ res.status(200).set({ 'Content-Type': 'text/html' }).end(page)
60
+ } catch (e) {
61
+ if (!isProduction) {
62
+ vite.ssrFixStacktrace(e)
63
+ }
64
+ console.error(e)
65
+ res.status(500).end(e.message)
66
+ }
67
+ })
68
+
69
+ const port = process.env.PORT || 3000
70
+ app.listen(port, () => {
71
+ console.log(`Server running at http://localhost:${port}`)
72
+ })
73
+ }
74
+
75
+ createServer()
@@ -0,0 +1,40 @@
1
+ <template>
2
+ <div id="app">
3
+ <nav>
4
+ <a href="/">Home</a>
5
+ <a href="/about">About</a>
6
+ </nav>
7
+ <RouterView />
8
+ </div>
9
+ </template>
10
+
11
+ <script>
12
+ import { createRouterView as RouterView } from '@utopia/router'
13
+ </script>
14
+
15
+ <style>
16
+ #app {
17
+ font-family: system-ui, -apple-system, sans-serif;
18
+ max-width: 800px;
19
+ margin: 0 auto;
20
+ padding: 20px;
21
+ }
22
+
23
+ nav {
24
+ display: flex;
25
+ gap: 16px;
26
+ padding: 16px 0;
27
+ border-bottom: 1px solid #eee;
28
+ margin-bottom: 24px;
29
+ }
30
+
31
+ nav a {
32
+ color: #4a9eff;
33
+ text-decoration: none;
34
+ font-weight: 500;
35
+ }
36
+
37
+ nav a:hover {
38
+ text-decoration: underline;
39
+ }
40
+ </style>
@@ -0,0 +1,4 @@
1
+ import { hydrate } from '@matthesketh/utopia-runtime'
2
+ import App from './App.utopia'
3
+
4
+ hydrate(App, '#app')
@@ -0,0 +1,6 @@
1
+ import { renderToString } from '@matthesketh/utopia-server'
2
+ import App from './App.utopia'
3
+
4
+ export function render(_url: string): { html: string; css: string } {
5
+ return renderToString(App)
6
+ }
@@ -0,0 +1,9 @@
1
+ import { mount } from '@matthesketh/utopia-runtime'
2
+ import { createRouter } from '@matthesketh/utopia-router'
3
+ import App from './App.utopia'
4
+
5
+ // File-based routes are auto-generated by the vite plugin
6
+ import routes from 'virtual:utopia-routes'
7
+
8
+ createRouter(routes)
9
+ mount(App, '#app')
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <slot />
3
+ </template>
@@ -0,0 +1,62 @@
1
+ <template>
2
+ <div>
3
+ <h1>Welcome to UtopiaJS</h1>
4
+ <p>A compiler-first, signal-based UI framework.</p>
5
+
6
+ <div class="counter">
7
+ <p>Count: {{ count() }}</p>
8
+ <p>Doubled: {{ doubled() }}</p>
9
+ <button @click="increment">Increment</button>
10
+ <button @click="decrement">Decrement</button>
11
+ <button @click="reset">Reset</button>
12
+ </div>
13
+ </div>
14
+ </template>
15
+
16
+ <script>
17
+ import { signal, computed } from '@utopia/core'
18
+
19
+ const count = signal(0)
20
+ const doubled = computed(() => count() * 2)
21
+
22
+ function increment() {
23
+ count.update(n => n + 1)
24
+ }
25
+
26
+ function decrement() {
27
+ count.update(n => n - 1)
28
+ }
29
+
30
+ function reset() {
31
+ count.set(0)
32
+ }
33
+ </script>
34
+
35
+ <style scoped>
36
+ h1 {
37
+ color: #333;
38
+ }
39
+
40
+ .counter {
41
+ margin-top: 24px;
42
+ padding: 20px;
43
+ border-radius: 8px;
44
+ background: #f8f9fa;
45
+ }
46
+
47
+ button {
48
+ margin-right: 8px;
49
+ padding: 8px 16px;
50
+ border: 1px solid #ddd;
51
+ border-radius: 4px;
52
+ background: white;
53
+ cursor: pointer;
54
+ font-size: 14px;
55
+ }
56
+
57
+ button:hover {
58
+ background: #4a9eff;
59
+ color: white;
60
+ border-color: #4a9eff;
61
+ }
62
+ </style>
@@ -0,0 +1,19 @@
1
+ <template>
2
+ <div>
3
+ <h1>About</h1>
4
+ <p>UtopiaJS is a modern web framework featuring:</p>
5
+ <ul>
6
+ <li>Fine-grained reactivity with signals</li>
7
+ <li>Compiler-first approach — no virtual DOM</li>
8
+ <li>Single-file components (.utopia)</li>
9
+ <li>File-based routing</li>
10
+ <li>TypeScript-first</li>
11
+ </ul>
12
+ </div>
13
+ </template>
14
+
15
+ <style scoped>
16
+ ul {
17
+ line-height: 1.8;
18
+ }
19
+ </style>
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "resolveJsonModule": true,
11
+ "declaration": false,
12
+ "sourceMap": true,
13
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
14
+ "jsx": "preserve",
15
+ "baseUrl": ".",
16
+ "paths": {
17
+ "@/*": ["./src/*"]
18
+ }
19
+ },
20
+ "include": ["src/**/*.ts", "src/**/*.d.ts"],
21
+ "exclude": ["node_modules", "dist"]
22
+ }
@@ -0,0 +1,6 @@
1
+ import { defineConfig } from 'vite'
2
+ import utopia from '@matthesketh/utopia-vite-plugin'
3
+
4
+ export default defineConfig({
5
+ plugins: [utopia()],
6
+ })