Skip to content

Commit

Permalink
Merge branch 'main' into feat/srcbookdev#210-Auto-install-deps-when-c…
Browse files Browse the repository at this point in the history
…reating-a-new-TS-Srcbook-srcbookdev#210
  • Loading branch information
Aswanth-c committed Oct 9, 2024
2 parents b5ec179 + 1314f4c commit 5cb5bc0
Show file tree
Hide file tree
Showing 144 changed files with 4,225 additions and 1,293 deletions.
5 changes: 0 additions & 5 deletions .changeset/healthy-toys-marry.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/khaki-laws-breathe.md

This file was deleted.

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/**/*'],
};
17 changes: 17 additions & 0 deletions packages/api/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# @srcbook/api

## 0.0.9

### Patch Changes

- 459b18d: Deploy all packages
- Updated dependencies [459b18d]
- @srcbook/shared@0.0.9

## 0.0.8

### Patch Changes

- 24c841e: Update websocket client to pass context and connection
- a063c46: Fix uncaught exceptions in TsServer event handlers after srcbook deletion
- Updated dependencies [24c841e]
- @srcbook/shared@0.0.8

## 0.0.7

### Patch Changes
Expand Down
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.
Loading

0 comments on commit 5cb5bc0

Please sign in to comment.