Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pnpm*.yaml
packages/api/drizzle/*
packages/api/apps/templates/**/*
**/*.src.md
1 change: 1 addition & 0 deletions packages/api/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ module.exports = {
globals: {
Bun: false,
},
ignorePatterns: ['apps/templates/**/*'],
};
71 changes: 71 additions & 0 deletions packages/api/apps/app.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { CodeLanguageType, randomid, type AppType } from '@srcbook/shared';
import { db } from '../db/index.mjs';
import { type App as DBAppType, apps as appsTable } from '../db/schema.mjs';
import { createViteApp, deleteViteApp, pathToApp } from './disk.mjs';
import { CreateAppSchemaType } from './schemas.mjs';
import { asc, desc, eq } from 'drizzle-orm';
import { npmInstall } from '../exec.mjs';

function toSecondsSinceEpoch(date: Date): number {
return Math.floor(date.getTime() / 1000);
}

export function serializeApp(app: DBAppType): AppType {
return {
id: app.externalId,
name: app.name,
language: app.language as CodeLanguageType,
createdAt: toSecondsSinceEpoch(app.createdAt),
updatedAt: toSecondsSinceEpoch(app.updatedAt),
};
}

async function insert(
attrs: Pick<DBAppType, 'name' | 'language' | 'externalId'>,
): Promise<DBAppType> {
const [app] = await db.insert(appsTable).values(attrs).returning();
return app!;
}

export async function createApp(data: CreateAppSchemaType): Promise<DBAppType> {
const app = await insert({
name: data.name,
language: data.language,
externalId: randomid(),
});

await createViteApp(app);

// TODO: handle this better.
// This should be done somewhere else and surface issues or retries.
// Not awaiting here because it's "happening in the background".
npmInstall({
cwd: pathToApp(app.externalId),
stdout(data) {
console.log(data.toString('utf8'));
},
stderr(data) {
console.error(data.toString('utf8'));
},
onExit(code) {
console.log(`npm install exit code: ${code}`);
},
});

return app;
}

export async function deleteApp(id: string) {
await db.delete(appsTable).where(eq(appsTable.externalId, id));
await deleteViteApp(id);
}

export function loadApps(sort: 'asc' | 'desc') {
const sorter = sort === 'asc' ? asc : desc;
return db.select().from(appsTable).orderBy(sorter(appsTable.updatedAt));
}

export async function loadApp(id: string) {
const [app] = await db.select().from(appsTable).where(eq(appsTable.externalId, id));
return app;
}
170 changes: 170 additions & 0 deletions packages/api/apps/disk.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import fs from 'node:fs/promises';
import Path from 'node:path';
import { fileURLToPath } from 'node:url';
import { type App as DBAppType } from '../db/schema.mjs';
import { APPS_DIR } from '../constants.mjs';
import { toValidPackageName } from './utils.mjs';
import { Dirent } from 'node:fs';
import { FileType } from '@srcbook/shared';

export function pathToApp(id: string) {
return Path.join(APPS_DIR, id);
}

function pathToTemplate(template: string) {
return Path.resolve(fileURLToPath(import.meta.url), '..', 'templates', template);
}

export function deleteViteApp(id: string) {
return fs.rm(pathToApp(id), { recursive: true });
}

export async function createViteApp(app: DBAppType) {
const appPath = pathToApp(app.externalId);

// Use recursive because its parent directory may not exist.
await fs.mkdir(appPath, { recursive: true });

// Scaffold all the necessary project files.
await scaffold(app, appPath);

return app;
}

async function scaffold(app: DBAppType, destDir: string) {
const template = `react-${app.language}`;

function write(file: string, content?: string) {
const targetPath = Path.join(destDir, file);
return content === undefined
? copy(Path.join(templateDir, file), targetPath)
: fs.writeFile(targetPath, content, 'utf-8');
}

const templateDir = pathToTemplate(template);
const files = await fs.readdir(templateDir);
for (const file of files.filter((f) => f !== 'package.json')) {
await write(file);
}

const [pkgContents, idxContents] = await Promise.all([
fs.readFile(Path.join(templateDir, 'package.json'), 'utf-8'),
fs.readFile(Path.join(templateDir, 'index.html'), 'utf-8'),
]);

const pkg = JSON.parse(pkgContents);
pkg.name = toValidPackageName(app.name);
const updatedPkgContents = JSON.stringify(pkg, null, 2) + '\n';

const updatedIdxContents = idxContents.replace(
/<title>.*<\/title>/,
`<title>${app.name}</title>`,
);

await Promise.all([
write('package.json', updatedPkgContents),
write('index.html', updatedIdxContents),
]);
}

export function fileUpdated(app: DBAppType, file: FileType) {
const path = Path.join(pathToApp(app.externalId), file.path);
return fs.writeFile(path, file.source, 'utf-8');
}

async function copy(src: string, dest: string) {
const stat = await fs.stat(src);
if (stat.isDirectory()) {
return copyDir(src, dest);
} else {
return fs.copyFile(src, dest);
}
}

async function copyDir(srcDir: string, destDir: string) {
await fs.mkdir(destDir, { recursive: true });
const files = await fs.readdir(srcDir);
for (const file of files) {
const srcFile = Path.resolve(srcDir, file);
const destFile = Path.resolve(destDir, file);
await copy(srcFile, destFile);
}
}

// TODO: This does not scale.
export async function getProjectFiles(app: DBAppType) {
const projectDir = Path.join(APPS_DIR, app.externalId);

const { files, directories } = await getDiskEntries(projectDir, {
exclude: ['node_modules', 'dist'],
});

const nestedFiles = await Promise.all(
directories.flatMap(async (dir) => {
const entries = await fs.readdir(Path.join(projectDir, dir.name), {
withFileTypes: true,
recursive: true,
});
return entries.filter((entry) => entry.isFile());
}),
);

const entries = [...files, ...nestedFiles.flat()];

return Promise.all(
entries.map(async (entry) => {
const fullPath = Path.join(entry.parentPath, entry.name);
const relativePath = Path.relative(projectDir, fullPath);
const contents = await fs.readFile(fullPath);
const binary = isBinary(entry.name);
const source = !binary ? contents.toString('utf-8') : `TODO: handle this`;
return { path: relativePath, source, binary };
}),
);
}

async function getDiskEntries(projectDir: string, options: { exclude: string[] }) {
const result: { files: Dirent[]; directories: Dirent[] } = {
files: [],
directories: [],
};

for (const entry of await fs.readdir(projectDir, { withFileTypes: true })) {
if (options.exclude.includes(entry.name)) {
continue;
}

if (entry.isFile()) {
result.files.push(entry);
} else {
result.directories.push(entry);
}
}

return result;
}

// TODO: This does not scale.
// What's the best way to know whether a file is a "binary"
// file or not? Inspecting bytes for invalid utf8?
const TEXT_FILE_EXTENSIONS = [
'.ts',
'.cts',
'.mts',
'.tsx',
'.js',
'.cjs',
'.mjs',
'.jsx',
'.md',
'.markdown',
'.json',
'.css',
'.html',
];

function isBinary(basename: string) {
const isDotfile = basename.startsWith('.'); // Assume these are text for now, e.g., .gitignore
const isTextFile = TEXT_FILE_EXTENSIONS.includes(Path.extname(basename));
return !(isDotfile || isTextFile);
}
9 changes: 9 additions & 0 deletions packages/api/apps/schemas.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import z from 'zod';

export const CreateAppSchema = z.object({
name: z.string(),
language: z.union([z.literal('typescript'), z.literal('javascript')]),
prompt: z.string().optional(),
});

export type CreateAppSchemaType = z.infer<typeof CreateAppSchema>;
1 change: 1 addition & 0 deletions packages/api/apps/templates/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
These templates were copied from https://github.com/vitejs/vite/tree/main/packages/create-vite
38 changes: 38 additions & 0 deletions packages/api/apps/templates/react-javascript/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'

export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]
13 changes: 13 additions & 0 deletions packages/api/apps/templates/react-javascript/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
28 changes: 28 additions & 0 deletions packages/api/apps/templates/react-javascript/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "vite-react-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.10.0",
"@types/react": "^18.3.6",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^9.10.0",
"eslint-plugin-react": "^7.36.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0",
"vite": "^5.4.6"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions packages/api/apps/templates/react-javascript/src/App.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}

.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}

@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}

.card {
padding: 2em;
}

.read-the-docs {
color: #888;
}
Loading