Skip to content

Commit 07f6374

Browse files
authored
Introduce app building capabilities into Srcbook (#337)
* Create apps * Implement preview * Fix up some issues * Small cleanup * Fix lint * More lint fixes * Ignore prettier for templates
1 parent ac76dbb commit 07f6374

68 files changed

Lines changed: 2602 additions & 15 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
pnpm*.yaml
22
packages/api/drizzle/*
3+
packages/api/apps/templates/**/*
34
**/*.src.md

packages/api/.eslintrc.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ module.exports = {
1010
globals: {
1111
Bun: false,
1212
},
13+
ignorePatterns: ['apps/templates/**/*'],
1314
};

packages/api/apps/app.mts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { CodeLanguageType, randomid, type AppType } from '@srcbook/shared';
2+
import { db } from '../db/index.mjs';
3+
import { type App as DBAppType, apps as appsTable } from '../db/schema.mjs';
4+
import { createViteApp, deleteViteApp, pathToApp } from './disk.mjs';
5+
import { CreateAppSchemaType } from './schemas.mjs';
6+
import { asc, desc, eq } from 'drizzle-orm';
7+
import { npmInstall } from '../exec.mjs';
8+
9+
function toSecondsSinceEpoch(date: Date): number {
10+
return Math.floor(date.getTime() / 1000);
11+
}
12+
13+
export function serializeApp(app: DBAppType): AppType {
14+
return {
15+
id: app.externalId,
16+
name: app.name,
17+
language: app.language as CodeLanguageType,
18+
createdAt: toSecondsSinceEpoch(app.createdAt),
19+
updatedAt: toSecondsSinceEpoch(app.updatedAt),
20+
};
21+
}
22+
23+
async function insert(
24+
attrs: Pick<DBAppType, 'name' | 'language' | 'externalId'>,
25+
): Promise<DBAppType> {
26+
const [app] = await db.insert(appsTable).values(attrs).returning();
27+
return app!;
28+
}
29+
30+
export async function createApp(data: CreateAppSchemaType): Promise<DBAppType> {
31+
const app = await insert({
32+
name: data.name,
33+
language: data.language,
34+
externalId: randomid(),
35+
});
36+
37+
await createViteApp(app);
38+
39+
// TODO: handle this better.
40+
// This should be done somewhere else and surface issues or retries.
41+
// Not awaiting here because it's "happening in the background".
42+
npmInstall({
43+
cwd: pathToApp(app.externalId),
44+
stdout(data) {
45+
console.log(data.toString('utf8'));
46+
},
47+
stderr(data) {
48+
console.error(data.toString('utf8'));
49+
},
50+
onExit(code) {
51+
console.log(`npm install exit code: ${code}`);
52+
},
53+
});
54+
55+
return app;
56+
}
57+
58+
export async function deleteApp(id: string) {
59+
await db.delete(appsTable).where(eq(appsTable.externalId, id));
60+
await deleteViteApp(id);
61+
}
62+
63+
export function loadApps(sort: 'asc' | 'desc') {
64+
const sorter = sort === 'asc' ? asc : desc;
65+
return db.select().from(appsTable).orderBy(sorter(appsTable.updatedAt));
66+
}
67+
68+
export async function loadApp(id: string) {
69+
const [app] = await db.select().from(appsTable).where(eq(appsTable.externalId, id));
70+
return app;
71+
}

packages/api/apps/disk.mts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import fs from 'node:fs/promises';
2+
import Path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { type App as DBAppType } from '../db/schema.mjs';
5+
import { APPS_DIR } from '../constants.mjs';
6+
import { toValidPackageName } from './utils.mjs';
7+
import { Dirent } from 'node:fs';
8+
import { FileType } from '@srcbook/shared';
9+
10+
export function pathToApp(id: string) {
11+
return Path.join(APPS_DIR, id);
12+
}
13+
14+
function pathToTemplate(template: string) {
15+
return Path.resolve(fileURLToPath(import.meta.url), '..', 'templates', template);
16+
}
17+
18+
export function deleteViteApp(id: string) {
19+
return fs.rm(pathToApp(id), { recursive: true });
20+
}
21+
22+
export async function createViteApp(app: DBAppType) {
23+
const appPath = pathToApp(app.externalId);
24+
25+
// Use recursive because its parent directory may not exist.
26+
await fs.mkdir(appPath, { recursive: true });
27+
28+
// Scaffold all the necessary project files.
29+
await scaffold(app, appPath);
30+
31+
return app;
32+
}
33+
34+
async function scaffold(app: DBAppType, destDir: string) {
35+
const template = `react-${app.language}`;
36+
37+
function write(file: string, content?: string) {
38+
const targetPath = Path.join(destDir, file);
39+
return content === undefined
40+
? copy(Path.join(templateDir, file), targetPath)
41+
: fs.writeFile(targetPath, content, 'utf-8');
42+
}
43+
44+
const templateDir = pathToTemplate(template);
45+
const files = await fs.readdir(templateDir);
46+
for (const file of files.filter((f) => f !== 'package.json')) {
47+
await write(file);
48+
}
49+
50+
const [pkgContents, idxContents] = await Promise.all([
51+
fs.readFile(Path.join(templateDir, 'package.json'), 'utf-8'),
52+
fs.readFile(Path.join(templateDir, 'index.html'), 'utf-8'),
53+
]);
54+
55+
const pkg = JSON.parse(pkgContents);
56+
pkg.name = toValidPackageName(app.name);
57+
const updatedPkgContents = JSON.stringify(pkg, null, 2) + '\n';
58+
59+
const updatedIdxContents = idxContents.replace(
60+
/<title>.*<\/title>/,
61+
`<title>${app.name}</title>`,
62+
);
63+
64+
await Promise.all([
65+
write('package.json', updatedPkgContents),
66+
write('index.html', updatedIdxContents),
67+
]);
68+
}
69+
70+
export function fileUpdated(app: DBAppType, file: FileType) {
71+
const path = Path.join(pathToApp(app.externalId), file.path);
72+
return fs.writeFile(path, file.source, 'utf-8');
73+
}
74+
75+
async function copy(src: string, dest: string) {
76+
const stat = await fs.stat(src);
77+
if (stat.isDirectory()) {
78+
return copyDir(src, dest);
79+
} else {
80+
return fs.copyFile(src, dest);
81+
}
82+
}
83+
84+
async function copyDir(srcDir: string, destDir: string) {
85+
await fs.mkdir(destDir, { recursive: true });
86+
const files = await fs.readdir(srcDir);
87+
for (const file of files) {
88+
const srcFile = Path.resolve(srcDir, file);
89+
const destFile = Path.resolve(destDir, file);
90+
await copy(srcFile, destFile);
91+
}
92+
}
93+
94+
// TODO: This does not scale.
95+
export async function getProjectFiles(app: DBAppType) {
96+
const projectDir = Path.join(APPS_DIR, app.externalId);
97+
98+
const { files, directories } = await getDiskEntries(projectDir, {
99+
exclude: ['node_modules', 'dist'],
100+
});
101+
102+
const nestedFiles = await Promise.all(
103+
directories.flatMap(async (dir) => {
104+
const entries = await fs.readdir(Path.join(projectDir, dir.name), {
105+
withFileTypes: true,
106+
recursive: true,
107+
});
108+
return entries.filter((entry) => entry.isFile());
109+
}),
110+
);
111+
112+
const entries = [...files, ...nestedFiles.flat()];
113+
114+
return Promise.all(
115+
entries.map(async (entry) => {
116+
const fullPath = Path.join(entry.parentPath, entry.name);
117+
const relativePath = Path.relative(projectDir, fullPath);
118+
const contents = await fs.readFile(fullPath);
119+
const binary = isBinary(entry.name);
120+
const source = !binary ? contents.toString('utf-8') : `TODO: handle this`;
121+
return { path: relativePath, source, binary };
122+
}),
123+
);
124+
}
125+
126+
async function getDiskEntries(projectDir: string, options: { exclude: string[] }) {
127+
const result: { files: Dirent[]; directories: Dirent[] } = {
128+
files: [],
129+
directories: [],
130+
};
131+
132+
for (const entry of await fs.readdir(projectDir, { withFileTypes: true })) {
133+
if (options.exclude.includes(entry.name)) {
134+
continue;
135+
}
136+
137+
if (entry.isFile()) {
138+
result.files.push(entry);
139+
} else {
140+
result.directories.push(entry);
141+
}
142+
}
143+
144+
return result;
145+
}
146+
147+
// TODO: This does not scale.
148+
// What's the best way to know whether a file is a "binary"
149+
// file or not? Inspecting bytes for invalid utf8?
150+
const TEXT_FILE_EXTENSIONS = [
151+
'.ts',
152+
'.cts',
153+
'.mts',
154+
'.tsx',
155+
'.js',
156+
'.cjs',
157+
'.mjs',
158+
'.jsx',
159+
'.md',
160+
'.markdown',
161+
'.json',
162+
'.css',
163+
'.html',
164+
];
165+
166+
function isBinary(basename: string) {
167+
const isDotfile = basename.startsWith('.'); // Assume these are text for now, e.g., .gitignore
168+
const isTextFile = TEXT_FILE_EXTENSIONS.includes(Path.extname(basename));
169+
return !(isDotfile || isTextFile);
170+
}

packages/api/apps/schemas.mts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import z from 'zod';
2+
3+
export const CreateAppSchema = z.object({
4+
name: z.string(),
5+
language: z.union([z.literal('typescript'), z.literal('javascript')]),
6+
prompt: z.string().optional(),
7+
});
8+
9+
export type CreateAppSchemaType = z.infer<typeof CreateAppSchema>;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
These templates were copied from https://github.com/vitejs/vite/tree/main/packages/create-vite
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import js from '@eslint/js'
2+
import globals from 'globals'
3+
import react from 'eslint-plugin-react'
4+
import reactHooks from 'eslint-plugin-react-hooks'
5+
import reactRefresh from 'eslint-plugin-react-refresh'
6+
7+
export default [
8+
{ ignores: ['dist'] },
9+
{
10+
files: ['**/*.{js,jsx}'],
11+
languageOptions: {
12+
ecmaVersion: 2020,
13+
globals: globals.browser,
14+
parserOptions: {
15+
ecmaVersion: 'latest',
16+
ecmaFeatures: { jsx: true },
17+
sourceType: 'module',
18+
},
19+
},
20+
settings: { react: { version: '18.3' } },
21+
plugins: {
22+
react,
23+
'react-hooks': reactHooks,
24+
'react-refresh': reactRefresh,
25+
},
26+
rules: {
27+
...js.configs.recommended.rules,
28+
...react.configs.recommended.rules,
29+
...react.configs['jsx-runtime'].rules,
30+
...reactHooks.configs.recommended.rules,
31+
'react/jsx-no-target-blank': 'off',
32+
'react-refresh/only-export-components': [
33+
'warn',
34+
{ allowConstantExport: true },
35+
],
36+
},
37+
},
38+
]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Vite + React</title>
8+
</head>
9+
<body>
10+
<div id="root"></div>
11+
<script type="module" src="/src/main.jsx"></script>
12+
</body>
13+
</html>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "vite-react-starter",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "vite build",
9+
"lint": "eslint .",
10+
"preview": "vite preview"
11+
},
12+
"dependencies": {
13+
"react": "^18.3.1",
14+
"react-dom": "^18.3.1"
15+
},
16+
"devDependencies": {
17+
"@eslint/js": "^9.10.0",
18+
"@types/react": "^18.3.6",
19+
"@types/react-dom": "^18.3.0",
20+
"@vitejs/plugin-react": "^4.3.1",
21+
"eslint": "^9.10.0",
22+
"eslint-plugin-react": "^7.36.1",
23+
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
24+
"eslint-plugin-react-refresh": "^0.4.12",
25+
"globals": "^15.9.0",
26+
"vite": "^5.4.6"
27+
}
28+
}
Lines changed: 1 addition & 0 deletions
Loading

0 commit comments

Comments
 (0)